Hacker News Reader annotated source
Back to indexHacker News reader in Torus!
2
Bootstrap the required globals from Torus, since we're not bundling
4for (const exportedName in Torus) {
5 window[exportedName] = Torus[exportedName];
6}
7
A few constants used through the app. The root URL for the Hacker News JSON API and the current time, for calculating relative datetimes.
10const APP_TITLE = 'Torus Hacker News';
11const HN_API_ROOT = 'https://hacker-news.firebaseio.com/v0';
12const NOW = new Date();
13
Used later in styles to keep colors consistent
15const BRAND_COLOR = '#1fada2';
16const LIGHT_BRAND_COLOR = '#a4abbb';
17
An abstraction over the Hacker News JSON API. Given a short path, it
expands it out and makes sure no requests are cached by the browser,
then returns the result in a JSON format. hnFetch()
also handles caching,
so multiple requests about the same thing only result on one request
using the CACHE
, which is a map from API routes to responses.
23const CACHE = new Map();
24const hnFetch = async (apiPath, skipCache) => {
25 if (!CACHE.has(apiPath) || skipCache) {
26 const result = await fetch(HN_API_ROOT + apiPath + '.json', {
27 cache: 'no-cache',
28 }).then(resp => resp.json());
29 CACHE.set(apiPath, result)
30 return result;
31 } else {
32 return CACHE.get(apiPath);
33 }
34}
35
This app also uses my personal screenshot service to deliver screenshot previews for HN story links. These are API details for that service.
38const LOOKING_GLASS_API_ROOT = 'https://glass.v37.co';
39const LOOKING_GLASS_TOKEN = 'lg_48461186351534';
40
A function to map a site's URL to the URL for a screenshot of that site, using the Looking Glass service.
43const getLookingGlassScreenshotURL = siteURL => {
44 return `${LOOKING_GLASS_API_ROOT}/screenshot?token=${
45 LOOKING_GLASS_TOKEN}&url=${encodeURI(siteURL)}`;
46}
47
Formats times into 24-hour format, which is what I personally prefer.
49const formatTime = date => {
50 const pad = num => num.toString().padStart(2, '0');
51 return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
52}
53
A date formatter that does relative dates in English for the last 2 days.
56const formatDate = unix => {
57 if (!unix) {
58 return 'some time ago';
59 }
60
61 const date = new Date(unix * 1000);
62 const delta = (NOW - date) / 1000;
63 if (delta < 60) {
64 return '< 1 min ago';
65 } else if (delta < 3600) {
66 return `${~~(delta / 60)} min ago`;
67 } else if (delta < 86400) {
68 return `${~~(delta / 3600)} hr ago`;
69 } else if (delta < 86400 * 2) {
70 return 'yesterday';
71 } else if (delta < 86400 * 3) {
72 return '2 days ago';
73 } else {
74 return date.toLocaleDateString() + ' ' + formatTime(date);
75 }
76}
77
Hacker News's text posts have content in escaped HTML, so this is the easiest way to display that HTML through Torus's renderer -- create a wrapper element, and pass that off to JDOM.
81const decodeHTML = html => {
82 const container = document.createElement('span');
83 container.innerHTML = html
84 return container;
85}
86
Shortcut function to go from a username to the link to the user's profile on news.ycombinator.com. I didn't make a user view in this app because I personally rarely visit profiles on HN.
90const stopProp = evt => evt.stopPropagation();
91const userLink = username => {
92 const href = `https://news.ycombinator.com/user?id=${username}`;
93 return jdom`<a href="${href}" target="_blank" onclick="${stopProp}" noreferrer>${username}</a>`;
94}
95
Records and Stores
97
In HN API, all stories, comments, and text posts inherit from Item
.
99class Item extends Record {
100
101 /* Items have the following attrs we care about:
102 * id: number
103 * type: 'job', 'story', 'comment', 'poll/pollopt' (which we ignore)
104 * by: username in string
105 * time: unix
106 * text: text content
107 * kids: kids in display order ranked
108 * url: string
109 * score: number of votes, or #votes for pollopt
110 * title: string
111 * descendants: total comment count
112 */
113 fetch() {
114 if (!this.loaded) {
115 return hnFetch(`/item/${this.id}`).then(data => {
116 const {id, ...attrs} = data;
117 this.update(attrs);
118 });
119 } else {
120 return Promise.resolve();
121 }
122 }
123
Use the type
property as a proxy to check if the rest
are already loaded, so we don't double-fetch.
126 get loaded() {
127 return this.get('type');
128 }
129
130}
131
Story inherits from Item but doesn't have any special powers yet.
133class Story extends Item {}
134
A collection of stories, used for rendering the top stories view.
136class StoryStore extends StoreOf(Story) {
137
slug
is the URL slug for the pages on HN: top, best, newest, etc.
139 constructor(slug, limit = 25) {
140 super();
141 this.slug = slug;
142 this.limit = limit;
143 this.pageNumber = 0;
144 }
145
Fetch all the new top stories from the API and reset the collection with those new stories.
148 fetch() {
149 return hnFetch('/' + this.slug).then(stories => {
150 const storyRecords = stories.slice(
151 this.pageNumber * this.limit,
152 (this.pageNumber + 1) * this.limit
153 ).map((id, idx) => {
154 return new Story(id, {
155 order: (this.pageNumber * this.limit) + 1 + idx,
156 })
157 });
158 this.reset(storyRecords);
159 for (const story of storyRecords) {
160 story.fetch();
161 }
162 });
163 }
164
Because the collection is paged with limit
, we need to be able to
flip to the next and previous pages. These take care of that.
It could be more efficient, but it works, and flipping pages is
not super frequent in the average HN reader use case, so it's not a catastrophe.
169 nextPage() {
170 router.go(`/page/${this.pageNumber + 1}`);
171 }
172
173 previousPage() {
174 router.go(`/page/${Math.max(0, this.pageNumber - 1)}`);
175 }
176
177 gotoPage(pageNumber) {
178 if (this.pageNumber !== pageNumber) {
179 this.pageNumber = pageNumber;
180 this.reset();
181 this.fetch();
182 }
183 }
184
185}
186
Comments are a kind of Item in the API
188class Comment extends Item {}
189
A collection of comments
191class CommentStore extends StoreOf(Comment) {
192
193 constructor(comment_ids = [], limit = 25) {
194 super();
195 this.resetWith(comment_ids);
Comment lists have a limit set so we don't load excessively,
but it's nice to know how many were hidden away as a result. That's
hiddenCount
.
199 this.hiddenCount = 0;
200 this.limit = limit;
201 }
202
203 fetch() {
204 for (const comment of this.records) {
205 comment.fetch();
206 }
207 }
208
Reset the collection with a new list of comment IDs (from a parent comment). This might seem like a wonky way to do things, but it's mirroring the API itself, which is also sort of weird.
212 resetWith(comment_ids) {
213 this.hiddenCount = Math.max(comment_ids.length - this.limit, 0);
214 this.reset(comment_ids.slice(0, this.limit).map(id => new Comment(id)));
215 }
216
217}
218
Components
220
Represents a listing in the main page's list of stories
222class StoryListing extends StyledComponent {
223
Stories stay collapsed in the list, and are expanded if they're viewed individually
225 init(story, _removeCallback, expanded = false) {
226 this.expanded = expanded;
227 this.setActiveStory = this.setActiveStory.bind(this);
228 this.bind(story, data => this.render(data));
229 }
230
231 styles() {
232 return css`
233 display: block;
234 margin-bottom: 24px;
235 cursor: pointer;
236 .listing {
237 display: flex;
238 flex-direction: row;
239 align-items: center;
240 justify-content: flex-start;
241 width: 100%;
242 &:hover .stats {
243 background: ${BRAND_COLOR};
244 color: #fff;
245 transform: translate(0, -4px);
246 &::after {
247 background: #fff;
248 }
249 }
250 }
251 .mono {
252 font-family: 'Menlo', 'Monaco', monospace;
253 }
254 .meta {
255 font-size: .9em;
256 opacity: .7;
257 span {
258 display: inline-block;
259 margin: 0 4px;
260 }
261 }
262 .url {
263 overflow: hidden;
264 text-overflow: ellipsis;
265 font-size: .8em;
266 }
267 .content {
268 color: #777;
269 font-size: 1em;
270 }
271 a.stats {
272 height: 64px;
273 width: 64px;
274 flex-shrink: 0;
275 text-align: center;
276 display: flex;
277 flex-direction: column;
278 align-items: center;
279 justify-content: center;
280 overflow: hidden;
281 border-radius: 6px;
282 background: #eee;
283 transition: background .2s, transform .2s;
284 position: relative;
285 text-decoration: none;
286 color: #333;
287 &::after {
288 content: '';
289 display: block;
290 height: 1px;
291 background: #555;
292 width: 52px;
293 position: absolute;
294 top: 31.5px;
295 left: 6px;
296 }
297 }
298 .score, .comments {
299 height: 32px;
300 width: 100%;
301 line-height: 32px;
302 }
303 .synopsis {
304 margin-left: 12px;
305 flex-shrink: 1;
306 overflow: hidden;
307 }
308 .previewWrapper {
309 display: block;
310 width: 100%;
311 max-width: 500px;
312 margin: 0 auto;
313 }
314 .preview {
315 position: relative;
316 margin: 18px auto 0 auto;
317 width: 100%;
318 height: 0;
319 padding-bottom: 75%;
320 box-shadow: 0 0 0 3px ${BRAND_COLOR};
321 box-sizing: border-box;
322 transition: opacity .2s;
323 .loadingIndicator {
324 position: absolute;
325 z-index: -1;
326 top: 50%;
327 left: 50%;
328 transform: translate(-50%, -50%);
329 font-size: 1.3em;
330 text-align: center;
331 width: 100%;
332 color: ${LIGHT_BRAND_COLOR};
333 }
334 img {
335 box-sizing: border-box;
336 width: 100%;
337 }
338 &:hover {
339 opacity: .7;
340 }
341 }
342 `;
343 }
344
345 getStoryPageURL() {
346 return `/story/${this.record.id}`;
347 }
348
To read more about a story (read the comments), we tell the router to go to the path, so the main app view can manage the rest.
351 setActiveStory(evt) {
352 if (evt) {
353 evt.preventDefault();
354 }
355 router.go(this.getStoryPageURL());
356 }
357
358 compose(attrs) {
359 const text = this.expanded ? decodeHTML(attrs.text || '') : ':: text post ::';
360
361 const score = attrs.score || 0;
362 const descendants = attrs.descendants || 0;
363 const title = attrs.title || '...';
364 const url = attrs.url || '';
365 const time = attrs.time || 0;
366 const author = attrs.by || '...';
367
PDFs don't work with puppeteer previews, so we don't check PDFs for previews
369 const preview = (this.expanded && url && !url.endsWith('.pdf')) ? (
370 jdom`<a class="previewWrapper" href="${url}" target="_blank" onclick="${stopProp}" noreferrer>
371 <div class="preview">
372 <div class="loadingIndicator">loading link preview ...</div>
373 <img alt="Screenshot of ${url}" src="${getLookingGlassScreenshotURL(url)}" />
374 </div>
375 </a>`
376 ) : '';
377
378 const createTitle = (score, commentCount) => {
379 const upvotes = score === 1 ? '1 upvote' : `${score} upvotes`;
380 const comments = commentCount === 1 ? '1 comment' : `${commentCount} comments`;
381 return upvotes + ', ' + comments;
382 }
383
384 return jdom`<li data-id=${attrs.id} onclick="${this.setActiveStory}">
385 <div class="listing">
386 <a class="stats mono" title="${createTitle(score, descendants)}"
387 href="${this.getStoryPageURL()}" onclick="${this.setActiveStory}">
388 <div class="score">${score}</div>
389 <div class="comments">${descendants}</div>
390 </a>
391 <div class="synopsis">
392 <div class="title">${attrs.order ? attrs.order + '.' : ''} ${title}</div>
393 <div class="url ${(url || !this.expanded) ? 'mono' : 'content'}">
394 ${url ? (
395 jdom`<a href="${url}" target="_blank" onclick="${stopProp}" noreferrer>${url}</a>`
396 ) : text}
397 </div>
398 <div class="meta">
399 <span class="time">${formatDate(time)}</span>
400 |
401 <span class="author">${userLink(author)}</span>
402 </div>
403 </div>
404 </div>
405 ${preview}
406 </li>`;
407 }
408
409}
410
Represents a single comment in a nested list of comments
412class CommentListing extends StyledComponent {
413
414 init(comment) {
415 this.folded = true;
Comments can always nest other comments as children. So each comment view has a collection of children comments.
418 this.comments = new CommentStore();
It's common for comment threads to never be expanded, so we optimize for the common case and don't even render the comment thread under this listing until expanded.
422 this.kidsList = null;
423 this.contentNode = null; // decoded HTML content, wrapped in a <span>
424 this.toggleFolded = this.toggleFolded.bind(this);
Anytime the kids
property on the parent comment changes,
reload the nested children comments.
427 this.bind(comment, data => {
428 this.comments.resetWith(data.kids || []);
429 if (!this.folded) {
430 this.comments.fetch();
431 }
The "text" value is immutable in this app, so the first time we get a non-null text value, create a contentNode from it and cache it so we don't need to keep parsing the HTML again.
436 if (!this.contentNode && data.text) {
437 this.contentNode = decodeHTML(data.text || '');
438 }
439 this.render(data);
440 });
441 }
442
443 styles() {
444 return css`
445 background: #eee;
446 margin-bottom: 12px;
447 padding: 12px;
448 border-radius: 6px;
449 cursor: pointer;
450 overflow: hidden;
451 .byline {
452 background: #aaa;
453 padding: 1px 8px;
454 border-radius: 6px;
455 color: #fff;
456 display: inline-block;
457 margin-bottom: 8px;
458 font-size: .9em;
459 a {
460 color: #fff;
461 }
462 }
463 .children {
464 margin-top: 12px;
465 margin-left: 12px;
466 }
467 code {
468 display: block;
469 overflow: auto;
470 max-width: 100%;
471 font-family: 'Menlo', 'Monaco', 'Courier', monospace;
472 }
473 @media (max-width: 600px) {
474 .text {
475 font-size: .95em;
476 line-height: 1.4em;
477 }
478 }
479 `;
480 }
481
The user can click/tap on the comment block to collapse or expand the comments nested under it.
484 toggleFolded(evt) {
485 evt.stopPropagation();
486 this.folded = !this.folded;
487 if (!this.folded && this.comments) {
488 this.comments.fetch();
489 }
490 if (!this.kidsList) {
491 this.kidsList = new CommentList(this.comments);
492 }
493 this.render();
494 }
495
496 compose(attrs) {
If a comment has been deleted, all the other information are zeroed out, so we have to treat it separately and show a placeholder.
499 if (attrs.deleted) {
500 return jdom`<div class="comment" onclick="${this.toggleFolded}">
501 <div class="byline">unknown</div>
502 <div class="text">- deleted comment -</div>
503 ${!this.folded ? (jdom`<div class="children">
504 ${this.kidsList.node}
505 </div>`) : ''}
506 </div>`;
507 }
508
509 const time = attrs.time || 0;
510 const author = attrs.by || '...';
511 const kids = attrs.kids || [];
512
513 return jdom`<div class="comment" onclick="${this.toggleFolded}">
514 <div class="byline">
515 ${formatDate(time)}
516 |
517 ${userLink(author)}
518 |
519 ${kids.length === 1 ? '1 reply' : kids.length + ' replies'}</div>
520 <div class="text">${this.contentNode}</div>
521 ${!this.folded ? (jdom`<div class="children">
522 ${this.kidsList.node}
523 </div>`) : ''}
524 </div>`;
525 }
526
527 remove() {
528 super.remove();
529 this.kidsList.remove();
530 }
531
532}
533
List of comments, both at the top level and nested under other comments
535class CommentList extends Styled(ListOf(CommentListing)) {
536
- elements automatically come with a default left padding we don't want.
538 styles() {
539 return css`padding-left: 0`;
540 }
541
542 compose() {
543 const nodes = this.nodes;
544 const truncatedMessage = (this.record.hiddenCount > 0 || nodes.length === 0)
545 ? `...${this.record.hiddenCount || 'no'} more comments` : '';
546 return jdom`<ul>
547 ${nodes}
548 ${truncatedMessage}
549 </ul>`;
550 }
551
552}
553
List of stories that appears on the main/home page. Most of the
main page styles are handled in App
, so we just use this component
to clear margins on the <ul>
.
557class StoryList extends Styled(ListOf(StoryListing)) {
558
559 styles() {
560 return css`
561 padding-left: 0;
562 .loadingMessage {
563 margin: 52px 0;
564 font-style: italic;
565 }
566 `;
567 }
568
569 compose() {
570 const nodes = this.nodes;
On slow connections, the list of stories may take a second or two to load. Rather than awkwardly showing an empty list wit no stories, let's show a message.
573 return jdom`<ul>
574 ${nodes.length ? nodes : jdom`<div class="loadingMessage">loading your stories...</div>`}
575 </ul>`;
576 }
577
578}
579
A StoryPage
is the page showing an individual story and any comments under it.
It holds both a story listing view, as well as a comment list view.
582class StoryPage extends Component {
583
584 init(story, expanded = false) {
Listing of the story this page is about, in expanded form
586 this.listing = new StoryListing(story, null, expanded);
A list of comments for this story
588 this.comments = new CommentStore();
589 this.commentList = new CommentList(this.comments);
When the list of children comments for the story loads/changes, re-render the comment list.
592 this.bind(story, data => {
593 this.comments.resetWith(data.kids || []);
594 this.comments.fetch();
595 this.render(data);
596 });
597 }
598
599 compose() {
600 return jdom`<section>
601 ${this.listing.node}
602 ${this.commentList.node}
603 <a href="https://news.ycombinator.com/item?id=${this.record.id}" target="_blank" noreferrer>
604 See on news.ycombinator.com
605 </a>
606 </section>`;
607 }
608
609 remove() {
610 super.remove();
611 if (this.commentList) {
612 this.commentList.remove();
613 }
614 }
615
616}
617
Main app view
619class App extends StyledComponent {
620
621 init(router) {
Active story is null iff we're looking at the main page / list of stories
623 this.activeStory = null;
624 this.activePage = null;
625
We load the top stories list from the HN API. There are others, but I really never read them so yeah.
628 this.stories = new StoryStore('topstories', 20);
629 this.list = new StoryList(this.stories);
Fetch the first page of stories
631 this.stories.fetch();
632
633 this.nextPage = this.nextPage.bind(this);
634 this.previousPage = this.previousPage.bind(this);
635 this.homeClick = this.homeClick.bind(this);
636
Define our routing actions.
638 this.bind(router, ([name, params]) => {
639 switch (name) {
640 case 'story': {
641 let story = this.stories.find(+params.storyID);
Story sometimes doesn't exist in our collection, if we're going directly to a story link from another page. In this case, we want to just fetch information about the story itself manually.
646 if (!story) {
647 story = new Story(+params.storyID);
648 story.fetch().then(() => {
649 document.title = `${story.get('title')} | ${APP_TITLE}`;
650 });
651 } else {
652 document.title = `${story.get('title')} | ${APP_TITLE}`;
653 }
654 this.setActiveStory(story);
655 break;
656 }
657 case 'page': {
658 const pageNumber = isNaN(+params.pageNumber) ? 0 : +params.pageNumber;
659 if (pageNumber === 0) {
660 router.go('/', {replace: true});
661 } else {
662 this.setActiveStory(null);
663 document.title = APP_TITLE;
664 this.stories.gotoPage(pageNumber);
665 }
666 break;
667 }
668 default:
The default route is just the main page, '/'
.
670 this.setActiveStory(null);
671 document.title = APP_TITLE;
672 this.stories.gotoPage(0);
673 break;
674 }
675 });
676 }
677
678 styles() {
679 return css`
680 font-family: system-ui, 'Helvetica', 'Roboto', sans-serif;
681 color: #333;
682 box-sizing: border-box;
683 padding: 14px;
684 padding-bottom: 24px;
685 line-height: 1.5em;
686 max-width: 800px;
687 margin: 0 auto;
688 h1 {
689 cursor: pointer;
690 }
691 a {
692 color: ${BRAND_COLOR};
693 &:visited {
694 color: ${LIGHT_BRAND_COLOR}
695 }
696 }
697 a.pageLink {
698 display: inline-block;
699 color: #fff;
700 background: ${BRAND_COLOR};
701 text-decoration: none;
702 padding: 6px 10px;
703 border: 0;
704 font-size: 1em;
705 margin-right: 12px;
706 border-radius: 6px;
707 transition: opacity .2s;
708 &:hover {
709 opacity: .7;
710 }
711 }
712 footer {
713 margin: 32px 0;
714 color: #aaa;
715 font-style: italic;
716 }
717 `;
718 }
719
Used to set an active story for the whole app. Called by the router logic.
721 setActiveStory(story) {
722 if (this.activeStory !== story) {
723 this.activeStory = story;
724 if (story) {
725 this.activePage = new StoryPage(story, true);
726 } else {
727 this.activePage = null;
728 }
729 this.resetScroll();
730 this.render();
731 }
732 }
733
734 nextPage(evt) {
735 evt.preventDefault();
736 this.stories.nextPage();
737 this.render();
738 this.resetScroll();
739 }
740
741 previousPage(evt) {
742 evt.preventDefault();
743 this.stories.previousPage();
744 this.render();
745 this.resetScroll();
746 }
747
748 homeClick() {
749 router.go('/');
750 }
751
When views switch, it's nice to automatically scroll up to the top of the page to read the new stuff. This does that.
754 resetScroll() {
755 requestAnimationFrame(() => {
756 requestAnimationFrame(() => {
757 document.scrollingElement.scrollTop = 0;
758 });
759 });
760 }
761
762 compose() {
763 return jdom`<main>
764 <h1 onclick="${this.homeClick}">
765 ${this.activePage ? '👈' : '🏠'} Hacker News
766 </h1>
767 ${this.activeStory ? (
768 this.activePage.node
769 ) : (
770 jdom`<div>
771 ${this.list.node}
772 <a class="pageLink" href="/page/${Math.max(0, this.stories.pageNumber - 1)}"
773 title="previous page"
774 onclick="${this.previousPage}">👈 prev</button>
775 <a class="pageLink" href="/page/${this.stories.pageNumber + 1}"
776 title="next page"
777 onclick="${this.nextPage}">next 👉</button>
778 </div>`
779 )}
780 <footer>This HN reader was made with
781 <a href="https://linus.zone/torus" target="_blank" noreferrer>Torus</a>
782 and <3 by
783 <a href="https://linus.zone/now" target="_blank" noreferrer>Linus</a>
784 </footer>
785 </main>`;
786 }
787
788}
789
Let's define our routes!
791const router = new Router({
792 story: '/story/:storyID',
793 page: '/page/:pageNumber',
794 default: '/',
795});
796
Create the app instance, which we define earlier to be called with a router, and mount it to the DOM.
799const app = new App(router);
800document.body.appendChild(app.node);
801