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`
${item.hls ? html`
Streaming format — plays natively in Safari.
` : ''}
`; } return html`
${item.alt ?? ''}
`; } function renderMedia(media: Media[]): Raw { if (media.length === 0) return html``; const many = media.length > 1; return html`
${many ? html`
1 / ${media.length}
` : ''}
${media.map((item, index) => renderItem(item, index))}
`; } function renderText(segment: Segment, post: Post): Raw { if (!segment.text && !segment.title) return html``; return html`
${segment.title ? html`

${segment.title}

` : ''} ${segment.text ? linkify(segment.text, post.platform) : ''}
`; } function renderWhen(postedAt: string | undefined): Raw { if (!postedAt) return html``; const date = new Date(postedAt); if (Number.isNaN(date.getTime())) return html``; return html``; } /** * 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`
${avatar ? html`` : ''} ${quoted.author.displayName ? html`${quoted.author.displayName}` : ''} ${quoted.author.handle} ${renderWhen(quoted.postedAt)}
${quoted.text ? html`
${linkify(quoted.text, post.platform)}
` : ''} ${renderMedia(quoted.media)} ${quoted.url ? html` Open the quoted post on ${post.platformLabel} ` : ''}
`; } /** * 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`
${threaded && segment.isAnchor ? html`

the post you followed

` : ''} ${above ? renderText(segment, post) : ''} ${renderMedia(segment.media)} ${above ? '' : renderText(segment, post)} ${segment.quoted ? renderQuoted(segment.quoted, post) : ''}
`; } /** 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``; } /** * 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`${alt}` .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 `
` 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`
${comment.author} ${marks.map((mark) => html`${mark}`)} ${typeof comment.score === 'number' ? html`${comment.score} ${Math.abs(comment.score) === 1 ? 'point' : 'points'}` : ''} ${shortWhen(comment.postedAt)} ${descendantsOf(comment) > 0 ? html`+${descendantsOf(comment)} ${ descendantsOf(comment) === 1 ? 'reply' : 'replies' }` : ''} ${comment.text ? html`
${renderMarkdown(comment.text, renderCommentImage)}
` : ''} ${comment.replies.length || comment.moreReplies ? html`
${comment.replies.map((reply) => renderComment(reply, depth + 1))} ${comment.moreReplies ? html`

${comment.moreReplies} more ${ comment.moreReplies === 1 ? 'reply' : 'replies' }, on Reddit

` : ''}
` : ''}
`; } /** * 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`

Comments

${ total !== undefined && total > shown ? html`${shown} of ${total}` : html`${shown}` }, in ${threads} ${threads === 1 ? 'thread' : 'threads'}
${comments.map((comment) => renderComment(comment, 0))}
${post.moreComments ? html`

${post.moreComments} more, behind “load more” on Reddit.

` : ''}
`; } 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`
${badge(post.platform, post.platformLabel)}
${avatar ? html`` : ''}
${post.author.displayName ? html`${post.author.displayName}` : ''} ${post.author.handle}
${post.segments.map((segment) => renderSegment(segment, post, threaded))}
${renderComments(post)}`; const who = post.author.displayName ?? post.author.handle; return layout(`${who} on ${post.platformLabel}`, body); }