Show the author's own chain on Bluesky and Threads
CI / Typecheck, test, build (pull_request) Successful in 46s
CI / Typecheck, test, build (pull_request) Successful in 46s
People write in chains on both, and a link into one arrives pointing at a single post out of several. Showing only that post loses the thing that was being said. Other people's replies are a different matter: they are a conversation rather than the thing that was shared, and on a busy post there are hundreds of them. A Post is now a list of Segments instead of one body. Most platforms produce exactly one and say so through oneSegment(); the two that thread produce the whole chain, with isAnchor marking the post that was actually linked, which need not be the first. Bluesky walks parent upward and the author's own replies downward, stopping at the first post by anyone else. That needs depth and parentHeight on getPostThread, which drags the entire reply tree along -- a few hundred KB on a popular post -- because there is no way to ask the API for one author's branch. Threads is harder to read. The page ships the linked post, the author's follow-ups, other people's replies and a pile of unrelated recommendations, all as flat thread_items containers with no nesting to go on. What separates a follow-up from a stranger's reply is that a follow-up is the author replying to themselves; a reply from someone else carries the same reply_to_author with a different name on it. The first post of a chain replies to nothing at all, so it is reachable only by walking backwards from the post that answers it -- a test caught that, when linking the second post of a thread returned just the one post. Fixtures for both are real captures. The Bluesky one keeps two of every level's outside replies rather than pruning them away, because a filter is only worth testing against the thing it is supposed to exclude. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { config } from '../config.ts';
|
||||
import { ResolveError, type Media, type Post } from '../types.ts';
|
||||
import { ResolveError, type Media, type Post, type Segment } from '../types.ts';
|
||||
import { captureJson } from '../browser/capture.ts';
|
||||
import { hostMatcher } from './url.ts';
|
||||
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||
@@ -26,7 +26,13 @@ type BskyPost = {
|
||||
indexedAt?: string;
|
||||
};
|
||||
|
||||
type ThreadResponse = { thread?: { post?: BskyPost } };
|
||||
type ThreadNode = {
|
||||
post?: BskyPost;
|
||||
parent?: ThreadNode;
|
||||
replies?: ThreadNode[];
|
||||
};
|
||||
|
||||
type ThreadResponse = { thread?: ThreadNode };
|
||||
|
||||
/** `profile/<actor>/post/<rkey>` is the only shape that carries a post. */
|
||||
function parsePath(pathRest: string): { actor: string; rkey: string } | undefined {
|
||||
@@ -75,7 +81,54 @@ export function mediaFromEmbed(embed: Embed | undefined): Media[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function toPost(post: BskyPost, originalUrl: string): Post {
|
||||
function segmentOf(post: BskyPost, isAnchor: boolean): Segment {
|
||||
return {
|
||||
...(post.record?.text ? { text: post.record.text } : {}),
|
||||
media: mediaFromEmbed(post.embed),
|
||||
...(post.record?.createdAt ? { postedAt: post.record.createdAt } : {}),
|
||||
...(isAnchor ? { isAnchor: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the author's own chain around the linked post: their earlier posts
|
||||
* leading up to it, and their own follow-ups after it.
|
||||
*
|
||||
* Everyone else's replies are left out. They are a conversation, not the
|
||||
* thing that was shared, and on a busy post there are hundreds of them.
|
||||
*/
|
||||
export function selfThread(thread: ThreadNode): Segment[] {
|
||||
const anchor = thread.post;
|
||||
const handle = anchor?.author?.handle;
|
||||
if (!anchor || !handle) return [];
|
||||
|
||||
const before: Segment[] = [];
|
||||
for (let node = thread.parent; node?.post?.author?.handle === handle; node = node.parent) {
|
||||
// Walking up, so each is older than the last.
|
||||
before.unshift(segmentOf(node.post, false));
|
||||
}
|
||||
|
||||
const after: Segment[] = [];
|
||||
let node: ThreadNode | undefined = thread;
|
||||
const guard = new Set<string>();
|
||||
for (;;) {
|
||||
// A chain continues through the author's own reply. Where they left
|
||||
// several, the earliest is the one the rest hangs off.
|
||||
const next: ThreadNode | undefined = (node?.replies ?? [])
|
||||
.filter((r) => r.post?.author?.handle === handle && r.post.uri && !guard.has(r.post.uri))
|
||||
.sort((a, b) =>
|
||||
(a.post?.record?.createdAt ?? '').localeCompare(b.post?.record?.createdAt ?? ''))[0];
|
||||
if (!next?.post?.uri) break;
|
||||
guard.add(next.post.uri);
|
||||
after.push(segmentOf(next.post, false));
|
||||
node = next;
|
||||
}
|
||||
|
||||
return [...before, segmentOf(anchor, true), ...after];
|
||||
}
|
||||
|
||||
export function toPost(thread: ThreadNode, originalUrl: string): Post {
|
||||
const post = thread.post ?? {};
|
||||
const handle = post.author?.handle ?? 'unknown';
|
||||
return {
|
||||
platform: 'bluesky',
|
||||
@@ -86,10 +139,8 @@ export function toPost(post: BskyPost, originalUrl: string): Post {
|
||||
...(post.author?.displayName ? { displayName: post.author.displayName } : {}),
|
||||
...(post.author?.avatar ? { avatar: { url: post.author.avatar } } : {}),
|
||||
},
|
||||
...(post.record?.text ? { text: post.record.text } : {}),
|
||||
textPosition: 'above',
|
||||
media: mediaFromEmbed(post.embed),
|
||||
...(post.record?.createdAt ? { postedAt: post.record.createdAt } : {}),
|
||||
segments: selfThread(thread),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,12 +163,15 @@ async function resolve({ page, originalUrl, pathRest }: ResolveContext): Promise
|
||||
}
|
||||
|
||||
const uri = `at://${did}/app.bsky.feed.post/${parsed.rkey}`;
|
||||
// Deep enough to follow a chain of any reasonable length. The cost is that
|
||||
// the reply tree comes too -- a few hundred KB on a busy post -- and there
|
||||
// is no way to ask for one author's branch.
|
||||
const direct = await page.request
|
||||
.get(`${API}/app.bsky.feed.getPostThread?depth=0&parentHeight=0&uri=${encodeURIComponent(uri)}`)
|
||||
.get(`${API}/app.bsky.feed.getPostThread?depth=12&parentHeight=12&uri=${encodeURIComponent(uri)}`)
|
||||
.then((r) => (r.ok() ? (r.json() as Promise<ThreadResponse>) : undefined))
|
||||
.catch(() => undefined);
|
||||
|
||||
if (direct?.thread?.post) return toPost(direct.thread.post, originalUrl);
|
||||
if (direct?.thread?.post) return toPost(direct.thread, originalUrl);
|
||||
|
||||
// Fall back to loading the post the way a person would and catching the
|
||||
// same call as it goes past.
|
||||
@@ -127,7 +181,7 @@ async function resolve({ page, originalUrl, pathRest }: ResolveContext): Promise
|
||||
const captured = captureJson<ThreadResponse>(page, (url) => url.includes('getPostThread?'));
|
||||
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
|
||||
const body = await captured.wait(config.navigationTimeoutMs);
|
||||
if (body?.thread?.post) return toPost(body.thread.post, originalUrl);
|
||||
if (body?.thread?.post) return toPost(body.thread, originalUrl);
|
||||
|
||||
throw new ResolveError('Bluesky returned no post for that URL', 'bluesky', originalUrl);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ResolveError, type Media, type Post } from '../types.ts';
|
||||
import { ResolveError, oneSegment, type Media, type Post } from '../types.ts';
|
||||
import { readOpenGraph } from '../browser/capture.ts';
|
||||
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts';
|
||||
import { extractJsonObjectAfterKey } from './scan.ts';
|
||||
@@ -294,10 +294,12 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
|
||||
? { avatar: { url: scraped.avatar, fetchHeaders: metaHeaders(REFERER) } }
|
||||
: {}),
|
||||
},
|
||||
...(scraped.text ? { text: scraped.text } : {}),
|
||||
textPosition: 'below',
|
||||
media: scraped.media,
|
||||
...(scraped.postedAt ? { postedAt: scraped.postedAt } : {}),
|
||||
segments: oneSegment({
|
||||
...(scraped.text ? { text: scraped.text } : {}),
|
||||
media: scraped.media,
|
||||
...(scraped.postedAt ? { postedAt: scraped.postedAt } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+141
-34
@@ -1,18 +1,36 @@
|
||||
import { ResolveError, type Media, type Post } from '../types.ts';
|
||||
import { ResolveError, type Media, type Post, type Segment } from '../types.ts';
|
||||
import { readOpenGraph } from '../browser/capture.ts';
|
||||
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts';
|
||||
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders, type MetaMediaNode } from './meta-media.ts';
|
||||
import { hostMatcher } from './url.ts';
|
||||
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||
|
||||
const REFERER = 'https://www.threads.com/';
|
||||
|
||||
/** One post as Threads ships it, alongside the media fields Instagram shares. */
|
||||
type ThreadsPost = MetaMediaNode & {
|
||||
code?: string;
|
||||
taken_at?: number;
|
||||
caption?: { text?: string };
|
||||
user?: { username?: string };
|
||||
text_post_app_info?: {
|
||||
reply_to_author?: { username?: string };
|
||||
};
|
||||
};
|
||||
|
||||
type ThreadContainer = {
|
||||
thread_items?: Array<{ post?: ThreadsPost }>;
|
||||
};
|
||||
|
||||
function handleFromPath(pathRest: string): string | undefined {
|
||||
const match = /^(@[^/]+)/.exec(pathRest);
|
||||
return match?.[1];
|
||||
return /^(@[^/]+)/.exec(pathRest)?.[1];
|
||||
}
|
||||
|
||||
function codeFromPath(pathRest: string): string | undefined {
|
||||
return /\/post\/([A-Za-z0-9_-]+)/.exec(`/${pathRest}`)?.[1];
|
||||
}
|
||||
|
||||
/** Threads ships its Relay payloads in `<script type="application/json">`
|
||||
* tags, several dozen of them, in an order that is not worth relying on. */
|
||||
* tags, several dozen of them, in an order not worth relying on. */
|
||||
async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
|
||||
const raw = await ctx.page
|
||||
.evaluate(() =>
|
||||
@@ -31,60 +49,149 @@ async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
|
||||
});
|
||||
}
|
||||
|
||||
async function fromOpenGraph(ctx: ResolveContext): Promise<{ media: Media[]; text?: string }> {
|
||||
const og = await readOpenGraph(ctx.page);
|
||||
const fetchHeaders = metaHeaders(REFERER);
|
||||
/** Every post the page shipped, from the thread containers it renders. */
|
||||
export function collectPosts(root: unknown): ThreadsPost[] {
|
||||
const seen = new Set<unknown>();
|
||||
const queue: unknown[] = [root];
|
||||
const posts: ThreadsPost[] = [];
|
||||
|
||||
if (og['og:video']) {
|
||||
return {
|
||||
media: [{
|
||||
kind: 'video',
|
||||
url: og['og:video'],
|
||||
fetchHeaders,
|
||||
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
|
||||
}],
|
||||
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||
};
|
||||
for (let i = 0; i < queue.length; i += 1) {
|
||||
const value = queue[i];
|
||||
if (value === null || typeof value !== 'object' || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
queue.push(...value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const items = (record as ThreadContainer).thread_items;
|
||||
if (Array.isArray(items)) {
|
||||
for (const item of items) {
|
||||
if (item?.post?.code) posts.push(item.post);
|
||||
}
|
||||
}
|
||||
queue.push(...Object.values(record));
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The author's own chain, and nothing else.
|
||||
*
|
||||
* The page carries three kinds of post: the linked one, the author's
|
||||
* follow-ups, and a pile of other people's replies and recommendations. A
|
||||
* follow-up is the author replying to themselves, which is exactly what
|
||||
* `reply_to_author` distinguishes — someone else replying to the same post
|
||||
* carries the same field with a different name on it.
|
||||
*/
|
||||
export function selfThread(posts: ThreadsPost[], anchorCode: string | undefined): ThreadsPost[] {
|
||||
const anchor = anchorCode ? posts.find((p) => p.code === anchorCode) : undefined;
|
||||
const author = anchor?.user?.username;
|
||||
if (!anchor || !author) return [];
|
||||
|
||||
// Same author, oldest first. Everyone else is a reply or a recommendation.
|
||||
const mine = [...new Map(
|
||||
posts.filter((p) => p.user?.username === author && p.code).map((p) => [p.code, p]),
|
||||
).values()].sort((a, b) => (a.taken_at ?? 0) - (b.taken_at ?? 0));
|
||||
|
||||
const at = mine.findIndex((p) => p.code === anchor.code);
|
||||
if (at === -1) return [anchor];
|
||||
|
||||
// A post continues the one before it when it is the author replying to
|
||||
// themselves. Walk out from the anchor in both directions for as long as
|
||||
// that holds: the link may point at the middle of a thread, and the first
|
||||
// post of a thread is not a reply to anything, so it can only be reached
|
||||
// backwards from the one that answers it.
|
||||
const continues = (post: ThreadsPost | undefined): boolean =>
|
||||
post?.text_post_app_info?.reply_to_author?.username === author;
|
||||
|
||||
let first = at;
|
||||
while (first > 0 && continues(mine[first])) first -= 1;
|
||||
|
||||
let last = at;
|
||||
while (last + 1 < mine.length && continues(mine[last + 1])) last += 1;
|
||||
|
||||
return mine.slice(first, last + 1);
|
||||
}
|
||||
|
||||
function segmentOf(post: ThreadsPost, isAnchor: boolean): Segment {
|
||||
return {
|
||||
media: og['og:image'] ? [{ kind: 'image', url: og['og:image'], fetchHeaders }] : [],
|
||||
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||
...(post.caption?.text ? { text: post.caption.text } : {}),
|
||||
media: mediaFromMetaNode(post, REFERER),
|
||||
...(post.taken_at ? { postedAt: new Date(post.taken_at * 1000).toISOString() } : {}),
|
||||
...(isAnchor ? { isAnchor: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The floor, when the structured payload is not there to be read. */
|
||||
async function fromOpenGraph(ctx: ResolveContext): Promise<Segment | undefined> {
|
||||
const og = await readOpenGraph(ctx.page);
|
||||
const fetchHeaders = metaHeaders(REFERER);
|
||||
const media: Media[] = [];
|
||||
|
||||
if (og['og:video']) {
|
||||
media.push({
|
||||
kind: 'video',
|
||||
url: og['og:video'],
|
||||
fetchHeaders,
|
||||
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
|
||||
});
|
||||
} else if (og['og:image']) {
|
||||
media.push({ kind: 'image', url: og['og:image'], fetchHeaders });
|
||||
}
|
||||
|
||||
const text = og['og:description'];
|
||||
if (media.length === 0 && !text) return undefined;
|
||||
return { media, ...(text ? { text } : {}), isAnchor: true };
|
||||
}
|
||||
|
||||
async function resolve(ctx: ResolveContext): Promise<Post> {
|
||||
const { page, originalUrl, pathRest } = ctx;
|
||||
|
||||
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const payloads = await inlinePayloads(ctx);
|
||||
const node = findMetaMedia(payloads);
|
||||
const chain = selfThread(collectPosts(payloads), codeFromPath(pathRest));
|
||||
|
||||
let media = node ? mediaFromMetaNode(node, REFERER) : [];
|
||||
let text = findCaption(payloads);
|
||||
let segments: Segment[] = chain.map((post) =>
|
||||
segmentOf(post, post.code === codeFromPath(pathRest)),
|
||||
);
|
||||
|
||||
if (media.length === 0 || !text) {
|
||||
const og = await fromOpenGraph(ctx);
|
||||
if (media.length === 0) media = og.media;
|
||||
text ??= og.text;
|
||||
if (segments.length === 0) {
|
||||
// No thread containers. Fall back to whatever single post is readable.
|
||||
const node = findMetaMedia(payloads);
|
||||
const caption = findCaption(payloads);
|
||||
if (node ?? caption) {
|
||||
segments = [{
|
||||
media: node ? mediaFromMetaNode(node, REFERER) : [],
|
||||
...(caption ? { text: caption } : {}),
|
||||
isAnchor: true,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
// A Threads post is often only words, so unlike the others an empty media
|
||||
// list is a perfectly good result — but nothing at all is a failure.
|
||||
if (media.length === 0 && !text) {
|
||||
if (segments.length === 0) {
|
||||
const og = await fromOpenGraph(ctx);
|
||||
if (og) segments = [og];
|
||||
}
|
||||
|
||||
// A Threads post is often only words, so empty media is a fine result --
|
||||
// but nothing at all is a failure.
|
||||
if (segments.length === 0) {
|
||||
throw new ResolveError('Threads returned nothing for that URL', 'threads', originalUrl);
|
||||
}
|
||||
|
||||
const handle = handleFromPath(pathRest) ?? '@threads';
|
||||
const handle = chain[0]?.user?.username ?? handleFromPath(pathRest)?.slice(1) ?? 'threads';
|
||||
return {
|
||||
platform: 'threads',
|
||||
platformLabel: 'Threads',
|
||||
originalUrl,
|
||||
author: { handle },
|
||||
...(text ? { text } : {}),
|
||||
author: { handle: `@${handle}` },
|
||||
textPosition: 'above',
|
||||
media,
|
||||
segments,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+16
-12
@@ -1,5 +1,5 @@
|
||||
import type { Page } from 'playwright';
|
||||
import { ChallengeError, ResolveError, type Media, type Post } from '../types.ts';
|
||||
import { ChallengeError, ResolveError, oneSegment, type Media, type Post } from '../types.ts';
|
||||
import { cookieHeaderFor } from '../browser/pool.ts';
|
||||
import { CHALLENGE_SELECTORS, park } from '../challenge/registry.ts';
|
||||
import { config } from '../config.ts';
|
||||
@@ -157,12 +157,14 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
|
||||
...(item.author?.nickname ? { displayName: item.author.nickname } : {}),
|
||||
...(avatar ? { avatar: { url: avatar, fetchHeaders: await tiktokHeaders(avatar) } } : {}),
|
||||
},
|
||||
...(item.desc ? { text: item.desc } : {}),
|
||||
textPosition: 'below',
|
||||
media,
|
||||
...(Number.isFinite(created) && created > 0
|
||||
? { postedAt: new Date(created * 1000).toISOString() }
|
||||
: {}),
|
||||
segments: oneSegment({
|
||||
...(item.desc ? { text: item.desc } : {}),
|
||||
media,
|
||||
...(Number.isFinite(created) && created > 0
|
||||
? { postedAt: new Date(created * 1000).toISOString() }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -190,13 +192,15 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
|
||||
platformLabel: 'TikTok',
|
||||
originalUrl,
|
||||
author: { handle: handleFrom(landed) ?? handleFrom(originalUrl) ?? '@tiktok' },
|
||||
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||
textPosition: 'below',
|
||||
media: [{
|
||||
kind: og['og:video'] ? 'video' : 'image',
|
||||
url,
|
||||
fetchHeaders: await tiktokHeaders(url),
|
||||
}],
|
||||
segments: oneSegment({
|
||||
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||
media: [{
|
||||
kind: og['og:video'] ? 'video' : 'image',
|
||||
url,
|
||||
fetchHeaders: await tiktokHeaders(url),
|
||||
}],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+11
-9
@@ -1,5 +1,5 @@
|
||||
import { config } from '../config.ts';
|
||||
import { ResolveError, type Media, type Post } from '../types.ts';
|
||||
import { ResolveError, oneSegment, type Media, type Post } from '../types.ts';
|
||||
import { captureJson } from '../browser/capture.ts';
|
||||
import { hostMatcher } from './url.ts';
|
||||
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||
@@ -88,15 +88,17 @@ export function toPost(result: TweetResult, originalUrl: string): Post {
|
||||
// `_normal` is a 48px thumbnail; `_x96` is the same file at 96.
|
||||
...(avatar ? { avatar: { url: avatar.replace('_normal', '_x96') } } : {}),
|
||||
},
|
||||
...(result.text ? { text: result.text } : {}),
|
||||
textPosition: 'above',
|
||||
// A quote post usually carries no media of its own — the picture being
|
||||
// talked about belongs to the post it quotes, and that is the thing
|
||||
// worth showing.
|
||||
media: mediaFromDetails(
|
||||
result.mediaDetails?.length ? result.mediaDetails : result.quoted_tweet?.mediaDetails,
|
||||
),
|
||||
...(result.created_at ? { postedAt: result.created_at } : {}),
|
||||
segments: oneSegment({
|
||||
...(result.text ? { text: result.text } : {}),
|
||||
// A quote post usually carries no media of its own — the picture being
|
||||
// talked about belongs to the post it quotes, and that is the thing
|
||||
// worth showing.
|
||||
media: mediaFromDetails(
|
||||
result.mediaDetails?.length ? result.mediaDetails : result.quoted_tweet?.mediaDetails,
|
||||
),
|
||||
...(result.created_at ? { postedAt: result.created_at } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+32
-14
@@ -1,6 +1,6 @@
|
||||
import { proxyUrlFor } from '../media/registry.ts';
|
||||
import type { Media, Post } from '../types.ts';
|
||||
import { html, type Raw } from './html.ts';
|
||||
import { anchorOf, type Media, type Post, type Segment } from '../types.ts';
|
||||
import { html, raw, type Raw } from './html.ts';
|
||||
import { badge, layout, originalUrlBlock } from './layout.ts';
|
||||
import { linkify } from './text.ts';
|
||||
|
||||
@@ -54,14 +54,14 @@ function renderMedia(media: Media[]): Raw {
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderText(post: Post): Raw {
|
||||
if (!post.text) return html``;
|
||||
return html`<div class="text">${linkify(post.text, post.platform)}</div>`;
|
||||
function renderText(segment: Segment, post: Post): Raw {
|
||||
if (!segment.text) return html``;
|
||||
return html`<div class="text">${linkify(segment.text, post.platform)}</div>`;
|
||||
}
|
||||
|
||||
function renderWhen(post: Post): Raw {
|
||||
if (!post.postedAt) return html``;
|
||||
const date = new Date(post.postedAt);
|
||||
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',
|
||||
@@ -69,11 +69,31 @@ function renderWhen(post: Post): Raw {
|
||||
})}</time>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"') : ''}>
|
||||
${threaded && segment.isAnchor
|
||||
? html`<p class="segment__mark">the post you followed</p>`
|
||||
: ''}
|
||||
${above ? renderText(segment, post) : ''}
|
||||
${renderMedia(segment.media)}
|
||||
${above ? '' : renderText(segment, post)}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function renderPost(post: Post): string {
|
||||
const avatar = post.author.avatar ? proxyUrlFor(post.author.avatar) : undefined;
|
||||
const above = post.textPosition === 'above';
|
||||
const threaded = post.segments.length > 1;
|
||||
const anchor = anchorOf(post);
|
||||
|
||||
const body = html`<article class="post" data-platform="${post.platform}">
|
||||
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">
|
||||
@@ -85,12 +105,10 @@ export function renderPost(post: Post): string {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
${above ? renderText(post) : ''}
|
||||
${renderMedia(post.media)}
|
||||
${above ? '' : renderText(post)}
|
||||
${post.segments.map((segment) => renderSegment(segment, post, threaded))}
|
||||
|
||||
<footer class="post__foot">
|
||||
${renderWhen(post)}
|
||||
${renderWhen(anchor?.postedAt)}
|
||||
${originalUrlBlock(post.originalUrl, post.platformLabel)}
|
||||
</footer>
|
||||
</article>`;
|
||||
|
||||
+28
-4
@@ -40,6 +40,22 @@ export type Author = {
|
||||
avatar?: Asset;
|
||||
};
|
||||
|
||||
/**
|
||||
* One post. Usually a whole `Post` is a single segment, but on the platforms
|
||||
* where people write in chains — Bluesky and Threads — the author's own
|
||||
* follow-ups belong with the one that was linked, and other people's replies
|
||||
* do not.
|
||||
*/
|
||||
export type Segment = {
|
||||
text?: string;
|
||||
media: Media[];
|
||||
/** ISO 8601. */
|
||||
postedAt?: string;
|
||||
/** The post the link actually pointed at. Only meaningful when a thread
|
||||
* has more than one segment. */
|
||||
isAnchor?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single shape every adapter produces and the renderer consumes. Adding
|
||||
* a platform means producing one of these; nothing downstream changes.
|
||||
@@ -52,15 +68,23 @@ export type Post = {
|
||||
* the copy button hands back. */
|
||||
originalUrl: string;
|
||||
author: Author;
|
||||
text?: string;
|
||||
/** Fixed per platform: the ones that lead with words put the text above
|
||||
* the media, the ones that lead with pictures put it below. */
|
||||
textPosition: 'above' | 'below';
|
||||
media: Media[];
|
||||
/** ISO 8601. */
|
||||
postedAt?: string;
|
||||
/** In the order they were written. Never empty. */
|
||||
segments: Segment[];
|
||||
};
|
||||
|
||||
/** Most platforms have no notion of a chain, so their adapters use this. */
|
||||
export function oneSegment(segment: Segment): Segment[] {
|
||||
return [{ ...segment, isAnchor: true }];
|
||||
}
|
||||
|
||||
/** The segment the link pointed at, or the first one. */
|
||||
export function anchorOf(post: Post): Segment | undefined {
|
||||
return post.segments.find((s) => s.isAnchor) ?? post.segments[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a post cannot be resolved. Carries enough for the error card
|
||||
* to still be useful: which platform, and the link to hand back.
|
||||
|
||||
Reference in New Issue
Block a user