Hacker News Reader annotated source

Back to index

        

Hacker 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 &#60;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