CI / Typecheck, test, build (pull_request) Successful in 11s
A comment that was a picture rendered as either a link or, for a Giphy, the literal text ``. On r/aww that is most of the thread. Reddit writes an inline image as a token rather than an address, in three shapes: `` for a Giphy, `` for a subreddit emote, and `` for an image uploaded straight to the comment. The useful part is that all three tokens are keys in that same comment's own `media_metadata`, so this is one lookup and not three special cases. Nothing in the adapter has to know what Giphy is. The fourth shape is someone pasting the address of a picture, which on Reddit is how most images in comments actually arrive -- 117 of them against 21 Giphys in the sample I scanned. Those are shown as pictures too, decided by the file extension. A link that is not to an image stays a link. Animated ones take `s.gif` over `s.mp4` even though the MP4 is several times smaller: a GIF moves on its own in an `<img>`, and an MP4 would need a player element with autoplay, loop and muted set, for something the size of a postage stamp. Everything goes through the `/m/` proxy, like all other media. Without that a comment thread would have the reader's browser fetch dozens of files straight from Reddit, which is the one thing this whole app exists to avoid. Placing the image is the renderer's job, not the Markdown parser's, because the proxy is a render-time concern and markdown.ts knows nothing about it -- so it takes an optional `ImageRenderer` and, without one, an image stays a link exactly as before. The parser also learned `![...]` proper: the link rule was matching from the `[` and stranding the `!` as text. Verified on r/aww/comments/171dxph, which carries one Giphy and 77 pasted images: 35 render on the first page, all 35 load, all 35 through the proxy, no upstream address reaches the page, no token is left unresolved, and nothing overflows the column or scrolls the page sideways. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import { proxyUrlFor } from '../media/registry.ts';
|
|
import { anchorOf, type Comment, type Media, type Post, type Quoted, type Segment } from '../types.ts';
|
|
import { html, raw, type Raw } from './html.ts';
|
|
import { badge, layout, originalUrlBlock } from './layout.ts';
|
|
import { renderMarkdown } from './markdown.ts';
|
|
import { linkify } from './text.ts';
|
|
|
|
/**
|
|
* Put the ratio on the media itself, never on the figure around it. On the
|
|
* figure, at the full width of the column, a portrait video forces a box
|
|
* taller than the window and the rest of the post gets pushed off screen.
|
|
*/
|
|
function aspect(item: Media): string {
|
|
return item.width && item.height ? `aspect-ratio: ${item.width} / ${item.height};` : '';
|
|
}
|
|
|
|
function renderItem(item: Media, index: number): Raw {
|
|
const src = proxyUrlFor(item);
|
|
|
|
if (item.kind === 'video') {
|
|
const poster = item.poster ? proxyUrlFor(item.poster) : undefined;
|
|
return html`<figure class="item item--video" data-index="${index}">
|
|
<video
|
|
controls
|
|
playsinline
|
|
preload="metadata"
|
|
style="${aspect(item)}"
|
|
${poster ? html`poster="${poster}"` : ''}
|
|
><source src="${src}"></video>
|
|
${item.hls
|
|
? html`<figcaption class="item__note">Streaming format — plays natively in Safari.</figcaption>`
|
|
: ''}
|
|
</figure>`;
|
|
}
|
|
|
|
return html`<figure class="item item--image" data-index="${index}">
|
|
<img
|
|
src="${src}"
|
|
alt="${item.alt ?? ''}"
|
|
style="${aspect(item)}"
|
|
loading="${index === 0 ? 'eager' : 'lazy'}"
|
|
decoding="async"
|
|
>
|
|
</figure>`;
|
|
}
|
|
|
|
function renderMedia(media: Media[]): Raw {
|
|
if (media.length === 0) return html``;
|
|
|
|
const many = media.length > 1;
|
|
return html`<section class="media" data-count="${media.length}">
|
|
${many
|
|
? html`<div class="media__bar">
|
|
<span class="media__count"><span class="media__at">1</span> / ${media.length}</span>
|
|
<div class="media__views" role="group" aria-label="Layout">
|
|
<button type="button" class="media__view" data-view="swipe" aria-pressed="true">Swipe</button>
|
|
<button type="button" class="media__view" data-view="grid" aria-pressed="false">Grid</button>
|
|
</div>
|
|
</div>`
|
|
: ''}
|
|
<div class="media__rail">${media.map((item, index) => renderItem(item, index))}</div>
|
|
</section>`;
|
|
}
|
|
|
|
function renderText(segment: Segment, post: Post): Raw {
|
|
if (!segment.text && !segment.title) return html``;
|
|
return html`<div class="text">
|
|
${segment.title ? html`<h1 class="text__title">${segment.title}</h1>` : ''}
|
|
${segment.text ? linkify(segment.text, post.platform) : ''}
|
|
</div>`;
|
|
}
|
|
|
|
function renderWhen(postedAt: string | undefined): Raw {
|
|
if (!postedAt) return html``;
|
|
const date = new Date(postedAt);
|
|
if (Number.isNaN(date.getTime())) return html``;
|
|
return html`<time datetime="${date.toISOString()}">${date.toLocaleString('en-CA', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
})}</time>`;
|
|
}
|
|
|
|
/**
|
|
* The post being quoted, drawn as a post of its own inside the one quoting
|
|
* it: its author, its words, its pictures, and a way to open it.
|
|
*
|
|
* The author line is the point. Lifting only the media out — which is what
|
|
* this used to do — put someone else's picture under the quoter's name with
|
|
* nothing to say so.
|
|
*/
|
|
function renderQuoted(quoted: Quoted, post: Post): Raw {
|
|
const avatar = quoted.author.avatar ? proxyUrlFor(quoted.author.avatar) : undefined;
|
|
|
|
return html`<blockquote class="quote">
|
|
<header class="quote__who">
|
|
${avatar ? html`<img class="quote__avatar" src="${avatar}" alt="" loading="lazy">` : ''}
|
|
${quoted.author.displayName
|
|
? html`<span class="quote__name">${quoted.author.displayName}</span>`
|
|
: ''}
|
|
<span class="quote__handle">${quoted.author.handle}</span>
|
|
${renderWhen(quoted.postedAt)}
|
|
</header>
|
|
${quoted.text ? html`<div class="quote__text">${linkify(quoted.text, post.platform)}</div>` : ''}
|
|
${renderMedia(quoted.media)}
|
|
${quoted.url
|
|
? html`<a class="quote__open" href="${quoted.url}" rel="noopener noreferrer nofollow" target="_blank">
|
|
Open the quoted post on ${post.platformLabel}
|
|
</a>`
|
|
: ''}
|
|
</blockquote>`;
|
|
}
|
|
|
|
/**
|
|
* One post in the chain. When there is only one, the wrapper is invisible;
|
|
* when there are several, each is separated and the one that was actually
|
|
* linked is marked, since it may not be the first.
|
|
*/
|
|
function renderSegment(segment: Segment, post: Post, threaded: boolean): Raw {
|
|
const above = post.textPosition === 'above';
|
|
return html`<section class="segment" ${threaded && segment.isAnchor ? raw('data-anchor="true"') : ''} ${
|
|
segment.quoted ? raw('data-quoted="true"') : ''
|
|
}>
|
|
${threaded && segment.isAnchor
|
|
? html`<p class="segment__mark">the post you followed</p>`
|
|
: ''}
|
|
${above ? renderText(segment, post) : ''}
|
|
${renderMedia(segment.media)}
|
|
${above ? '' : renderText(segment, post)}
|
|
${segment.quoted ? renderQuoted(segment.quoted, post) : ''}
|
|
</section>`;
|
|
}
|
|
|
|
/** Short enough to sit on a comment's byline without wrapping. Absolute
|
|
* rather than "3 hours ago", because the page is cached for an hour and a
|
|
* relative time would quietly become wrong while it sat there. */
|
|
function shortWhen(postedAt: string | undefined): Raw {
|
|
if (!postedAt) return html``;
|
|
const date = new Date(postedAt);
|
|
if (Number.isNaN(date.getTime())) return html``;
|
|
return html`<time datetime="${date.toISOString()}">${date.toLocaleString('en-CA', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})}</time>`;
|
|
}
|
|
|
|
/**
|
|
* A picture inside a comment.
|
|
*
|
|
* Through the proxy like everything else — a comment full of `preview.redd.it`
|
|
* addresses would otherwise have the viewer's browser fetch every one of them
|
|
* straight from Reddit, which is the thing this whole app exists to avoid.
|
|
*
|
|
* No dimensions to reserve space with: the size is in the payload but not in
|
|
* the Markdown, so these are capped by the stylesheet and load at whatever
|
|
* shape they are.
|
|
*/
|
|
function renderCommentImage(url: string, alt: string): string {
|
|
return html`<img class="c__img" src="${proxyUrlFor({ url })}" alt="${alt}" loading="lazy" decoding="async">`
|
|
.value;
|
|
}
|
|
|
|
/** Everything hanging off a comment, however deep. Shown only while it is
|
|
* collapsed, so what a fold is hiding is never a mystery. */
|
|
function descendantsOf(comment: Comment): number {
|
|
return comment.replies.reduce((total, reply) => total + 1 + descendantsOf(reply), 0);
|
|
}
|
|
|
|
/**
|
|
* One comment and everything under it.
|
|
*
|
|
* A `<details>` per comment, so collapsing works with the stylesheet turned
|
|
* off and the keyboard alone, and takes the whole subtree with it because
|
|
* the replies are nested inside rather than listed alongside.
|
|
*/
|
|
function renderComment(comment: Comment, depth: number): Raw {
|
|
const marks = [
|
|
comment.isAuthor ? 'OP' : undefined,
|
|
comment.distinguished === 'moderator' ? 'MOD' : undefined,
|
|
comment.distinguished === 'admin' ? 'ADMIN' : undefined,
|
|
].filter((mark) => mark !== undefined);
|
|
|
|
return html`<details class="c" open data-depth="${depth}">
|
|
<summary class="c__head">
|
|
<span class="c__author">${comment.author}</span>
|
|
${marks.map((mark) => html`<span class="c__mark c__mark--${mark.toLowerCase()}">${mark}</span>`)}
|
|
${typeof comment.score === 'number'
|
|
? html`<span class="c__score">${comment.score} ${Math.abs(comment.score) === 1 ? 'point' : 'points'}</span>`
|
|
: ''}
|
|
${shortWhen(comment.postedAt)}
|
|
${descendantsOf(comment) > 0
|
|
? html`<span class="c__hidden">+${descendantsOf(comment)} ${
|
|
descendantsOf(comment) === 1 ? 'reply' : 'replies'
|
|
}</span>`
|
|
: ''}
|
|
</summary>
|
|
${comment.text
|
|
? html`<div class="c__body">${renderMarkdown(comment.text, renderCommentImage)}</div>`
|
|
: ''}
|
|
${comment.replies.length || comment.moreReplies
|
|
? html`<div class="c__replies">
|
|
${comment.replies.map((reply) => renderComment(reply, depth + 1))}
|
|
${comment.moreReplies
|
|
? html`<p class="c__more">${comment.moreReplies} more ${
|
|
comment.moreReplies === 1 ? 'reply' : 'replies'
|
|
}, on Reddit</p>`
|
|
: ''}
|
|
</div>`
|
|
: ''}
|
|
</details>`;
|
|
}
|
|
|
|
/**
|
|
* The conversation under the post, where the platform has one.
|
|
*
|
|
* Everything the first page carried, in the order it was ranked. What is
|
|
* missing is what was behind a "load more" — following those means going to
|
|
* the platform, so they are counted rather than pretended away.
|
|
*/
|
|
function renderComments(post: Post): Raw {
|
|
const comments = post.comments;
|
|
if (!comments?.length) return html``;
|
|
|
|
const threads = comments.length;
|
|
const shown = comments.reduce((total, comment) => total + 1 + descendantsOf(comment), 0);
|
|
const total = post.commentCount;
|
|
|
|
return html`<section class="thread" data-platform="${post.platform}">
|
|
<header class="thread__head">
|
|
<h2 class="thread__title">Comments</h2>
|
|
<span class="thread__count">${
|
|
total !== undefined && total > shown ? html`${shown} of ${total}` : html`${shown}`
|
|
}, in ${threads} ${threads === 1 ? 'thread' : 'threads'}</span>
|
|
<button type="button" class="thread__toggle" data-collapsed="false" hidden>Collapse all</button>
|
|
</header>
|
|
<div class="thread__list">${comments.map((comment) => renderComment(comment, 0))}</div>
|
|
${post.moreComments
|
|
? html`<p class="thread__more">${post.moreComments} more, behind “load more” on Reddit.</p>`
|
|
: ''}
|
|
</section>`;
|
|
}
|
|
|
|
export function renderPost(post: Post): string {
|
|
const avatar = post.author.avatar ? proxyUrlFor(post.author.avatar) : undefined;
|
|
const threaded = post.segments.length > 1;
|
|
const anchor = anchorOf(post);
|
|
|
|
const body = html`<article class="post" data-platform="${post.platform}" ${
|
|
threaded ? raw(`data-segments="${post.segments.length}"`) : ''
|
|
}>
|
|
<header class="post__head">
|
|
${badge(post.platform, post.platformLabel)}
|
|
<div class="who">
|
|
${avatar ? html`<img class="who__avatar" src="${avatar}" alt="" loading="lazy">` : ''}
|
|
<div class="who__names">
|
|
${post.author.displayName ? html`<span class="who__name">${post.author.displayName}</span>` : ''}
|
|
<span class="who__handle">${post.author.handle}</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
${post.segments.map((segment) => renderSegment(segment, post, threaded))}
|
|
|
|
<footer class="post__foot">
|
|
${renderWhen(anchor?.postedAt)}
|
|
${originalUrlBlock(post.originalUrl, post.platformLabel)}
|
|
</footer>
|
|
</article>
|
|
${renderComments(post)}`;
|
|
|
|
const who = post.author.displayName ?? post.author.handle;
|
|
return layout(`${who} on ${post.platformLabel}`, body);
|
|
}
|