/** The platforms antisocial understands. */ export type PlatformId = 'x' | 'threads' | 'instagram' | 'tiktok' | 'bluesky' | 'reddit'; /** * Something fetchable that lives on someone else's CDN. * * Most of these get rewritten to a `/m/` proxy URL before they reach the * page, because Instagram and TikTok reject requests that arrive without the * right `Referer` and cookies, and because proxying keeps the viewer's * browser from talking to the platform at all. */ export type Asset = { url: string; /** Headers the upstream CDN insists on. Held server-side, never in the page. */ fetchHeaders?: Record; /** Point the page straight at the CDN instead of proxying. Used for HLS, * where proxying would mean rewriting playlists and every segment. */ direct?: boolean; }; type MediaBase = Asset & { width?: number; height?: number; }; export type Media = | (MediaBase & { kind: 'image'; alt?: string }) | (MediaBase & { kind: 'video'; poster?: Asset; durationSec?: number; /** An HLS playlist rather than a progressive file. Safari plays these * natively; other browsers get a note. */ hls?: boolean; }); export type Author = { handle: string; displayName?: string; avatar?: Asset; }; /** * A post that the post being shown is talking about. * * Held apart from the segment quoting it rather than folded into it. The * words and the pictures belong to someone else, and showing them under the * quoter's name — which is what folding them in amounts to — tells the * reader something untrue about who said what. */ export type Quoted = { author: Author; text?: string; media: Media[]; /** ISO 8601. */ postedAt?: string; /** The quoted post's own URL, so it can be opened on its own. */ url?: string; }; /** * 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 = { /** Only Reddit gives a post a headline of its own. Everywhere else the * first words of the body do that job, so this is left unset. */ title?: string; text?: string; media: Media[]; /** ISO 8601. */ postedAt?: string; /** The post this one quotes, where there is one. */ quoted?: Quoted; /** The post the link actually pointed at. Only meaningful when a thread * has more than one segment. */ isAnchor?: boolean; }; /** * One comment, with the replies that hang off it. * * A tree rather than a flat list with depths: collapsing a comment has to * take everything under it along, and nesting is what makes that free. */ export type Comment = { /** Already prefixed, e.g. `u/someone`. `[deleted]` is left as it came. */ author: string; text?: string; /** ISO 8601. */ postedAt?: string; /** Absent when the platform is still hiding it on a new comment. */ score?: number; /** The author of the post, replying under it. */ isAuthor?: boolean; /** Marked by the platform as a moderator or admin comment. */ distinguished?: string; replies: Comment[]; /** Replies that exist upstream but were not on the page we were given. * Shown as a count, since following them means going to the platform. */ moreReplies?: number; }; /** * The single shape every adapter produces and the renderer consumes. Adding * a platform means producing one of these; nothing downstream changes. */ export type Post = { platform: PlatformId; /** Human label for the badge: "X", "Threads", ... */ platformLabel: string; /** Canonical original URL, cleaned of tracking parameters. This is what * the copy button hands back. */ originalUrl: string; author: Author; /** 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'; /** In the order they were written. Never empty. */ segments: Segment[]; /** * The conversation under the post, where the platform has one worth * showing. Only Reddit fills this in: elsewhere the replies are strangers * arguing beneath something that was shared for its own sake, but on * Reddit the thread is usually the point of the link. */ comments?: Comment[]; /** Top-level comments the first page did not carry. */ moreComments?: number; /** What the platform says the total is, which is larger than what we * show whenever `moreComments` is set. */ commentCount?: number; }; /** 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. */ export class ResolveError extends Error { readonly platform: PlatformId; readonly originalUrl: string; constructor(message: string, platform: PlatformId, originalUrl: string, options?: ErrorOptions) { super(message, options); this.name = 'ResolveError'; this.platform = platform; this.originalUrl = originalUrl; } } /** * A platform answered with a human-verification puzzle rather than the post. * * The page showing it is parked and still open, so the viewer can be handed * the puzzle to solve. Solving it leaves the resulting cookie in the shared * browser context, which is what makes the retry work. */ export class ChallengeError extends ResolveError { readonly challengeId: string; constructor(challengeId: string, platform: PlatformId, originalUrl: string) { super('A verification puzzle is in the way.', platform, originalUrl); this.name = 'ChallengeError'; this.challengeId = challengeId; } }