← ui

tweet

ui

a post on X drawn from its facts, no widget script: media, quotes, link cards

npx shadcn add @ag/tweet
Already installed? Preview an update
npx shadcn add @ag/tweet --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-13
  • The author's avatar and the tweet's photos arrive through Image: held until loaded, then faded in, hidden on error so the card's own tone shows. Installing tweet now brings image.
Updated 2026-10-13 · release notes

also called twitter

shelf social & links

uses person, web-preview, card-link, panel, video, image

two side by side in X's 16:9 frame, the text at reading size

02 · a quote

the quoted post drawn small inside, its X article as web-preview's x card

03 · a video

the richest mp4 under the embed's budget, its poster until played; a verified badge, a mention

Every post is a pure function of its facts. Fetching them is tweet-data's job, from the endpoint X's own embed reads, once, at authoring time: no widget script and no request when the page renders.

the demo above, verbatim

tweet.demo.tsx
import { Sample } from "@/app/samples"
import type { Tweet as TweetFacts } from "@/registry/base-nova/lib/tweet-data"
import { Tweet } from "@/registry/base-nova/ui/tweet"

// #region posts
/** Posts as data, pasted from `mise tweet <url>`. Nothing here fetched. */
const PHOTOS: TweetFacts = {
  id: "2101560333441610133",
  url: "https://x.com/Mappletons/status/2101560333441610133",
  author: {
    name: "Maggie Appleton",
    handle: "@Mappletons",
    avatar:
      "https://pbs.twimg.com/profile_images/1635665509512355840/-jFo36KR_200x200.jpg",
  },
  date: "2026-09-20T06:33:23.000Z",
  body: [
    {
      text: 'Can we agree to call Jev a "Decision Model"?\nI feel like they buried this in the explainations and docs.\nLanguage models output language. Decision models output decisions.',
    },
  ],
  media: [
    {
      kind: "photo",
      src: "https://pbs.twimg.com/media/HSo9u6-XUAA8PhA.jpg",
      width: 1638,
      height: 638,
    },
    {
      kind: "photo",
      src: "https://pbs.twimg.com/media/HSo9z9FW8AAj0zC.jpg",
      width: 1632,
      height: 428,
    },
  ],
  likes: 304,
  replies: 48,
}
const QUOTE: TweetFacts = {
  id: "2104646956551422036",
  url: "https://x.com/rocketalignment/status/2104646956551422036",
  author: {
    name: "🚀 Rocket",
    handle: "@rocketalignment",
    avatar:
      "https://pbs.twimg.com/profile_images/1980865852460158976/RoeuxAGX_200x200.jpg",
  },
  date: "2026-09-28T18:58:31.000Z",
  body: [
    {
      text: "Some people on Reddit are speculating that this account is fake but OpenAI confirmed to me that Joe does work there in the capacity he claims",
    },
  ],
  media: [],
  quote: {
    id: "2104335929293127851",
    url: "https://x.com/joedaroo/status/2104335929293127851",
    author: {
      name: "Joe",
      handle: "@joedaroo",
      avatar:
        "https://pbs.twimg.com/profile_images/2097167182526611457/UJBigEfF_200x200.jpg",
      verified: "blue",
    },
    date: "2026-09-27T22:22:36.000Z",
    body: [
      {
        text: "Took a minute to write a few words about security & safety as someone who lived through it all at OpenAI. I hope my thoughts help someone out there.",
      },
    ],
    media: [],
    card: {
      url: "https://x.com/i/article/2104258872957636608",
      site: "X",
      title: "Its not just the f*cking sandbox",
      description:
        "A lot of the perspective on all the AI incidents has been shared from the outside in, and little has been said from the inside looking out, through the lens of a security person living through it.",
      image: "https://pbs.twimg.com/media/HTQTvrnbYAAVg_-.jpg",
    },
    likes: 2849,
    replies: 440,
  },
  likes: 57,
  replies: 3,
}
const VIDEO: TweetFacts = {
  id: "2104674987164782598",
  url: "https://x.com/claudeai/status/2104674987164782598",
  author: {
    name: "Claude",
    handle: "@claudeai",
    avatar:
      "https://pbs.twimg.com/profile_images/1950950107937185792/QOfEjFoJ_200x200.jpg",
    verified: "blue",
  },
  date: "2026-09-28T20:49:54.000Z",
  body: [
    {
      text: "A thread of early experiments with Claude Sonnet 5.5.\n\nA fall foliage simulator by ",
    },
    { text: "@_re_pete", href: "https://x.com/_re_pete" },
    { text: ", made with Sonnet 5 vs Sonnet 5.5." },
  ],
  media: [
    {
      kind: "video",
      src: "https://video.twimg.com/amplify_video/2104672427187793920/vid/avc1/720x720/di5rQh2OGhgj0guK.mp4",
      poster:
        "https://pbs.twimg.com/amplify_video_thumb/2104672427187793920/img/in4Hl4l6NzD9hpWU.jpg",
      width: 1080,
      height: 1080,
    },
  ],
  likes: 7888,
  replies: 255,
}
// #endregion

export default function Demo() {
  return (
    <div className="space-y-16">
      <Sample
        name="photos"
        glance="large"
        with="posts"
        title="01 · a post with pictures"
        description="two side by side in X's 16:9 frame, the text at reading size"
      >
        <Tweet tweet={PHOTOS} className="mx-auto max-w-md" />
      </Sample>

      <Sample
        name="quote"
        title="02 · a quote"
        description="the quoted post drawn small inside, its X article as web-preview's x card"
      >
        <Tweet tweet={QUOTE} className="mx-auto max-w-xl" />
      </Sample>

      <Sample
        name="video"
        title="03 · a video"
        description="the richest mp4 under the embed's budget, its poster until played; a verified badge, a mention"
      >
        <Tweet tweet={VIDEO} className="mx-auto max-w-xl" />
      </Sample>

      <p className="max-w-prose text-foreground/60 text-sm">
        Every post is a pure function of its facts. Fetching them is{" "}
        <code>tweet-data</code>&apos;s job, from the endpoint X&apos;s own embed
        reads, once, at authoring time: no widget script and no request when the
        page renders.
      </p>
    </div>
  )
}

the source, every file an install writes

tweet.tsx
// A POST ON X, drawn from its facts (`tweet-data`): no widget script, no iframe, no
// request at render. The client's geometry on the page's own surfaces: the avatar and
// the names on one row with X's mark in the corner, the text at reading size with its
// links in X's blue, the media grid X lays out for one to four pictures, a quoted post
// as a smaller post inside, a link card through `web-preview`'s x style, the date and
// the counts at the foot.
//
// The whole post opens the post on X (`card-link`): click anywhere but a link, a
// control or a selection. Inside it the avatar and the names open the profile, the
// date and the mark open the post (the keyboard's way there), a quoted post opens
// itself, every link in the text is a link. Decorative chrome is aria-hidden. A server
// component; only the card's click handler runs in the browser.

import { Heart, MessageCircle } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
import type {
  TweetAuthor,
  Tweet as TweetFacts,
  TweetMedia,
  TweetRun,
} from "@/registry/base-nova/lib/tweet-data"
import { CardLink } from "@/registry/base-nova/ui/card-link"
import { Image } from "@/registry/base-nova/ui/image"
import { Panel } from "@/registry/base-nova/ui/panel"
import { Video } from "@/registry/base-nova/ui/video"
import { WebPreview } from "@/registry/base-nova/ui/web-preview"

export interface TweetProps {
  tweet: TweetFacts
  className?: string
}

const LINK = "text-[#1d9bf0] hover:underline"
const MUTED = "text-foreground/55"
const ANCHOR = { target: "_blank", rel: "noopener noreferrer" } as const

const DATE = new Intl.DateTimeFormat("en", {
  month: "short",
  day: "numeric",
  year: "numeric",
  timeZone: "UTC",
})
const COUNT = new Intl.NumberFormat("en", { notation: "compact" })

const BADGE: Record<NonNullable<TweetAuthor["verified"]>, string> = {
  blue: "#1d9bf0",
  business: "#e2b719",
  government: "#829aab",
}

function Verified({ kind }: { kind: NonNullable<TweetAuthor["verified"]> }) {
  return (
    <svg
      data-slot="tweet-verified"
      viewBox="0 0 22 22"
      className="size-[1.1em] shrink-0"
      role="img"
      aria-label={`${kind === "blue" ? "" : `${kind} `}verified account`}
    >
      <path
        fill={BADGE[kind]}
        d="M20.4 11c0-1.3-.8-2.4-2-3 .4-1.3.2-2.6-.7-3.6-.9-.9-2.3-1.1-3.6-.7-.6-1.2-1.7-2-3-2s-2.4.8-3 2c-1.3-.4-2.7-.2-3.6.7-.9.9-1.1 2.3-.7 3.6-1.2.6-2 1.7-2 3s.8 2.4 2 3c-.4 1.3-.2 2.7.7 3.6.9.9 2.3 1.1 3.6.7.6 1.2 1.7 2 3 2s2.4-.8 3-2c1.3.4 2.7.2 3.6-.7.9-.9 1.1-2.3.7-3.6 1.2-.6 2-1.7 2-3z"
      />
      <path
        fill="#fff"
        d="m9.6 14.9-3.3-3.3 1.3-1.3 2 2 4.6-4.9 1.3 1.2-5.9 6.3z"
      />
    </svg>
  )
}

function XMark() {
  return (
    <svg viewBox="0 0 24 24" className="size-5" aria-hidden="true">
      <path
        fill="currentColor"
        d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
      />
    </svg>
  )
}

function Body({ runs, className }: { runs: TweetRun[]; className?: string }) {
  return (
    <p
      data-slot="tweet-body"
      className={cn("whitespace-pre-wrap break-words", className)}
    >
      {runs.map((r, i) =>
        r.href ? (
          // biome-ignore lint/suspicious/noArrayIndexKey: runs are positional and never reorder
          <a key={i} href={r.href} className={LINK} {...ANCHOR}>
            {r.text}
          </a>
        ) : (
          r.text
        ),
      )}
    </p>
  )
}

/** One to four pictures as X lays them out: one at its own aspect (never taller than
 *  3:4), more in a 16:9 frame, two side by side, three as a tall one and a stack, four
 *  as a grid. */
const TALLEST = 3 / 4
const FRAME = 16 / 9
const GRID: Record<number, string> = {
  2: "grid-cols-2",
  3: "grid-cols-2 grid-rows-2 [&>*:first-child]:row-span-2",
  4: "grid-cols-2 grid-rows-2",
}

function Media({ media, url }: { media: TweetMedia[]; url: string }) {
  if (media.length > 4)
    throw new Error(`tweet ${url}: ${media.length} pictures, X shows four`)
  const one = media.length === 1 ? (media[0] as TweetMedia) : undefined
  const aspect = one ? Math.max(one.width / one.height, TALLEST) : FRAME
  return (
    <div
      data-slot="tweet-media"
      className={cn(
        "grid aspect-(--ag-tweet-aspect) gap-0.5 clip-rounded rounded-2xl border border-border",
        GRID[media.length],
      )}
      style={{ "--ag-tweet-aspect": String(aspect) } as React.CSSProperties}
    >
      {media.map((m, i) =>
        m.kind === "photo" ? (
          <Image
            // biome-ignore lint/suspicious/noArrayIndexKey: media are positional
            key={i}
            src={m.src}
            alt={m.alt ?? ""}
            width={m.width}
            height={m.height}
            loading="lazy"
            className="size-full min-h-0 object-cover"
          />
        ) : (
          // A video waits for the person and keeps the browser's own bar, as an
          // embed should; a gif plays on sight and loops, as X plays one.
          <Video
            // biome-ignore lint/suspicious/noArrayIndexKey: media are positional
            key={i}
            src={m.src}
            poster={m.poster}
            optimizePoster={false}
            play={m.kind === "gif" ? "visible" : "manual"}
            loop={m.kind === "gif"}
            preload="none"
            label={m.kind === "gif" ? "gif" : "video"}
            className="min-h-0 bg-black"
            videoClassName="object-contain"
          />
        ),
      )}
    </div>
  )
}

function Names({ author, small }: { author: TweetAuthor; small?: boolean }) {
  const profile = `https://x.com/${author.handle.slice(1)}`
  return (
    <a
      href={profile}
      {...ANCHOR}
      data-slot="tweet-author"
      className={cn(
        "flex min-w-0 gap-x-1 leading-5",
        small ? "items-center" : "flex-col",
      )}
    >
      <span className="flex min-w-0 items-center gap-1 font-bold hover:underline">
        <span className="truncate">{author.name}</span>
        {author.verified && <Verified kind={author.verified} />}
      </span>
      <span className={cn("truncate", MUTED)}>{author.handle}</span>
    </a>
  )
}

function Avatar({ author, size }: { author: TweetAuthor; size: string }) {
  return (
    <a
      href={`https://x.com/${author.handle.slice(1)}`}
      {...ANCHOR}
      // The names beside it are the same link; the keyboard visits it once.
      tabIndex={-1}
      className="shrink-0"
    >
      <Image
        src={author.avatar}
        alt={author.name}
        className={cn(
          size,
          "bg-foreground/8 object-cover",
          author.square ? "rounded-[22%]" : "rounded-full",
        )}
      />
    </a>
  )
}

function Quote({ tweet }: { tweet: TweetFacts }) {
  return (
    // The quoted post opens itself, not the post quoting it: the innermost card wins.
    // A post inside the post: a panel inside a frame, so the inset one (Panel).
    <Panel
      render={<CardLink href={tweet.url} />}
      data-slot="tweet-quote"
      bodyClassName="gap-2 p-3"
      className="relative text-[15px]"
    >
      <div className="flex items-center gap-1.5">
        <Avatar author={tweet.author} size="size-5" />
        <Names author={tweet.author} small />
        <span className={MUTED} aria-hidden="true">
          ·
        </span>
        <a
          href={tweet.url}
          {...ANCHOR}
          className={cn("shrink-0 hover:underline", MUTED)}
        >
          <time dateTime={tweet.date}>{DATE.format(new Date(tweet.date))}</time>
        </a>
      </div>
      {tweet.body.length > 0 && <Body runs={tweet.body} />}
      {tweet.media.length > 0 && <Media media={tweet.media} url={tweet.url} />}
      {tweet.card && <WebPreview facts={tweet.card} style="x" />}
    </Panel>
  )
}

export function Tweet({ tweet, className }: TweetProps) {
  return (
    // The whole post opens the post on X, as X's own embed does; a keyboard gets
    // there through the mark and the date.
    <Panel
      render={<CardLink href={tweet.url} />}
      data-slot="tweet"
      bodyClassName="gap-0 p-0"
      className={cn("font-sans text-[15px]", className)}
    >
      <article className="space-y-3 p-4">
        <header className="flex items-start gap-3">
          <Avatar author={tweet.author} size="size-10" />
          <div className="min-w-0 flex-1">
            <Names author={tweet.author} />
          </div>
          <a
            href={tweet.url}
            {...ANCHOR}
            aria-label="View on X"
            className="text-foreground/80 hover:text-foreground"
          >
            <XMark />
          </a>
        </header>
        {tweet.body.length > 0 && (
          <Body runs={tweet.body} className="text-[17px] leading-6" />
        )}
        {tweet.media.length > 0 && (
          <Media media={tweet.media} url={tweet.url} />
        )}
        {tweet.card && <WebPreview facts={tweet.card} style="x" />}
        {tweet.quote && <Quote tweet={tweet.quote} />}
        <a
          href={tweet.url}
          {...ANCHOR}
          className={cn("block hover:underline", MUTED)}
        >
          <time dateTime={tweet.date}>{DATE.format(new Date(tweet.date))}</time>
          {tweet.edited && " · edited"}
        </a>
        <footer
          data-slot="tweet-counts"
          className={cn(
            "flex gap-6 border-border border-t pt-3 font-medium",
            MUTED,
          )}
        >
          <a
            href={tweet.url}
            {...ANCHOR}
            className="flex items-center gap-1.5 hover:text-[#f91880]"
          >
            <Heart className="size-[1.15em]" aria-hidden="true" />
            {COUNT.format(tweet.likes)}
            <span className="sr-only"> likes</span>
          </a>
          <a
            href={tweet.url}
            {...ANCHOR}
            className="flex items-center gap-1.5 hover:text-[#1d9bf0]"
          >
            <MessageCircle className="size-[1.15em]" aria-hidden="true" />
            {COUNT.format(tweet.replies)}
            <span className="sr-only"> replies</span>
          </a>
        </footer>
      </article>
    </Panel>
  )
}
tweet-data.ts
// A POST ON X, as data: the facts `tweet` draws, and the one fetch that reads them.
//
// The source is the public syndication endpoint X's own embed widget reads (the one
// Vercel's react-tweet reads too): no key, no login, one GET per post. It runs at
// AUTHORING time, the web-preview decision: `mise tweet <url>` prints the facts, they
// ship beside the page, and a deploy never depends on X answering. Pictures on
// pbs.twimg.com and video on video.twimg.com are stable paths; keep them or mirror
// them, the facts do not care.
//
// Every shape the drawing does not know (a poll, a deleted post, a card it cannot read)
// is a THROW at fetch time, never a post that silently lost half of itself.

import type { Person } from "@/registry/base-nova/lib/person"
import type { Unfurl } from "@/registry/base-nova/lib/web-preview-facts"

export interface TweetAuthor extends Person {
  handle: string
  avatar: string
  /** The badge beside the name: X's blue, a business's gold, a government's grey. */
  verified?: "blue" | "business" | "government"
  /** Businesses wear a rounded square instead of a circle. */
  square?: boolean
}

/** A stretch of the text: plain, or a link with what X shows for it (a mention, a
 *  hashtag, a shortened url's display form). */
export interface TweetRun {
  text: string
  href?: string
}

export type TweetMedia =
  | { kind: "photo"; src: string; width: number; height: number; alt?: string }
  | {
      kind: "video" | "gif"
      src: string
      poster: string
      width: number
      height: number
    }

export interface Tweet {
  id: string
  url: string
  author: TweetAuthor
  /** ISO 8601. */
  date: string
  body: TweetRun[]
  media: TweetMedia[]
  /** The post this one quotes, drawn inside it. */
  quote?: Tweet
  /** The link card under the text (a page's unfurl, or an X article). */
  card?: Unfurl
  likes: number
  replies: number
  edited?: boolean
}

const ENTITIES: Record<string, string> = {
  amp: "&",
  lt: "<",
  gt: ">",
  quot: '"',
  "#39": "'",
}
function decode(s: string): string {
  return s.replace(/&(amp|lt|gt|quot|#39);/g, (_, k: string) => {
    return ENTITIES[k] as string
  })
}

/** The id inside any form of a post's address, or the id itself. */
export function tweetId(urlOrId: string): string {
  const id = /^\d+$/.test(urlOrId)
    ? urlOrId
    : /(?:twitter|x)\.com\/[^/]+\/status(?:es)?\/(\d+)/.exec(urlOrId)?.[1]
  if (!id) throw new Error(`tweet: not a post address: ${urlOrId}`)
  return id
}

// What the syndication endpoint answers with, the parts read here.
interface RawEntity {
  indices: [number, number]
}
interface RawTweet {
  __typename?: string
  id_str: string
  text: string
  display_text_range: [number, number]
  created_at: string
  favorite_count?: number
  conversation_count?: number
  reply_count?: number
  isEdited?: boolean
  user: {
    name: string
    screen_name: string
    profile_image_url_https: string
    profile_image_shape?: string
    is_blue_verified?: boolean
    verified_type?: string | null
  }
  entities?: {
    urls?: (RawEntity & {
      url: string
      expanded_url: string
      display_url: string
    })[]
    user_mentions?: (RawEntity & { screen_name: string })[]
    hashtags?: (RawEntity & { text: string })[]
    symbols?: (RawEntity & { text: string })[]
    media?: RawEntity[]
  }
  mediaDetails?: {
    type: "photo" | "video" | "animated_gif"
    media_url_https: string
    ext_alt_text?: string | null
    original_info: { width: number; height: number }
    video_info?: {
      variants: { content_type: string; bitrate?: number; url: string }[]
    }
  }[]
  quoted_tweet?: RawTweet
  card?: {
    url: string
    binding_values: Record<
      string,
      { string_value?: string; image_value?: { url: string } } | undefined
    >
  } | null
  article?: {
    rest_id: string
    title: string
    preview_text?: string
    cover_media?: { media_info?: { original_img_url?: string } }
  }
  poll?: unknown
}

/** The text between `display_text_range` as runs, entities as links. Indices count
 *  code points of the ESCAPED text (`&amp;` is five), so the text is sliced first and
 *  each slice decoded after. */
function bodyOf(t: RawTweet): TweetRun[] {
  const chars = Array.from(t.text)
  const [from, to] = t.display_text_range
  const e = t.entities ?? {}
  const links: { at: [number, number]; run: TweetRun }[] = [
    ...(e.urls ?? []).map((u) => ({
      at: u.indices,
      run: { text: u.display_url, href: u.expanded_url },
    })),
    ...(e.user_mentions ?? []).map((m) => ({
      at: m.indices,
      run: {
        text: `@${m.screen_name}`,
        href: `https://x.com/${m.screen_name}`,
      },
    })),
    ...(e.hashtags ?? []).map((h) => ({
      at: h.indices,
      run: {
        text: `#${h.text}`,
        href: `https://x.com/hashtag/${encodeURIComponent(h.text)}`,
      },
    })),
    ...(e.symbols ?? []).map((s) => ({
      at: s.indices,
      run: {
        text: `$${s.text}`,
        href: `https://x.com/search?q=%24${encodeURIComponent(s.text)}`,
      },
    })),
  ]
    .filter((l) => l.at[0] >= from && l.at[1] <= to)
    .sort((a, b) => a.at[0] - b.at[0])
  const runs: TweetRun[] = []
  let at = from
  for (const l of links) {
    if (l.at[0] < at) throw new Error(`tweet ${t.id_str}: entities overlap`)
    if (l.at[0] > at)
      runs.push({ text: decode(chars.slice(at, l.at[0]).join("")) })
    runs.push(l.run)
    at = l.at[1]
  }
  const tail = decode(chars.slice(at, to).join("")).trimEnd()
  if (tail) runs.push({ text: tail })
  return runs
}

/** The video to play inline: an embed in prose does not need the 1080p master, so the
 *  richest mp4 under this budget, else the leanest there is. */
const VIDEO_BUDGET_BPS = 2_000_000

function mediaOf(t: RawTweet): TweetMedia[] {
  return (t.mediaDetails ?? []).map((m) => {
    const { width, height } = m.original_info
    if (m.type === "photo")
      return {
        kind: "photo",
        src: m.media_url_https,
        width,
        height,
        ...(m.ext_alt_text ? { alt: m.ext_alt_text } : {}),
      }
    const mp4 = (m.video_info?.variants ?? [])
      .filter((v) => v.content_type === "video/mp4")
      .sort((a, b) => (a.bitrate ?? 0) - (b.bitrate ?? 0))
    if (!mp4.length) throw new Error(`tweet ${t.id_str}: a video with no mp4`)
    const pick =
      mp4.filter((v) => (v.bitrate ?? 0) <= VIDEO_BUDGET_BPS).at(-1) ??
      (mp4[0] as (typeof mp4)[number])
    return {
      kind: m.type === "animated_gif" ? "gif" : "video",
      src: pick.url,
      poster: m.media_url_https,
      width,
      height,
    }
  })
}

function cardOf(t: RawTweet): Unfurl | undefined {
  if (t.article)
    return {
      url: `https://x.com/i/article/${t.article.rest_id}`,
      site: "X",
      title: t.article.title,
      ...(t.article.preview_text
        ? { description: t.article.preview_text }
        : {}),
      ...(t.article.cover_media?.media_info?.original_img_url
        ? { image: t.article.cover_media.media_info.original_img_url }
        : {}),
    }
  if (!t.card) return undefined
  const v = t.card.binding_values
  const title = v.title?.string_value
  if (!title) throw new Error(`tweet ${t.id_str}: a card with no title`)
  const expanded = t.entities?.urls?.find((u) => u.url === t.card?.url)
  const image =
    v.thumbnail_image_large?.image_value?.url ??
    v.summary_photo_image_large?.image_value?.url ??
    v.photo_image_full_size_large?.image_value?.url
  const domain = v.domain?.string_value ?? v.vanity_url?.string_value
  return {
    url: expanded?.expanded_url ?? t.card.url,
    site: domain ?? new URL(expanded?.expanded_url ?? t.card.url).hostname,
    title,
    ...(v.description?.string_value
      ? { description: v.description.string_value }
      : {}),
    ...(image ? { image } : {}),
  }
}

function fromRaw(t: RawTweet): Tweet {
  if (t.poll) throw new Error(`tweet ${t.id_str}: polls are not drawn yet`)
  const u = t.user
  const verified =
    u.verified_type === "Business"
      ? "business"
      : u.verified_type === "Government"
        ? "government"
        : u.is_blue_verified
          ? "blue"
          : undefined
  const card = cardOf(t)
  const quote = t.quoted_tweet ? fromRaw(t.quoted_tweet) : undefined
  // X drops the link that ends a post when the card under the text IS that link.
  const body = bodyOf(t)
  if (card && body.at(-1)?.href === card.url) {
    body.pop()
    const last = body.at(-1)
    if (last && !last.href) {
      last.text = last.text.trimEnd()
      if (!last.text) body.pop()
    }
  }
  return {
    id: t.id_str,
    url: `https://x.com/${u.screen_name}/status/${t.id_str}`,
    author: {
      name: u.name,
      handle: `@${u.screen_name}`,
      // `_normal` is 48 px; `_200x200` is crisp at the sizes a post draws on a 2x screen.
      avatar: u.profile_image_url_https.replace("_normal.", "_200x200."),
      ...(verified ? { verified } : {}),
      ...(u.profile_image_shape === "Square" ? { square: true } : {}),
    },
    date: new Date(t.created_at).toISOString(),
    body,
    media: mediaOf(t),
    ...(quote ? { quote } : {}),
    ...(card ? { card } : {}),
    likes: t.favorite_count ?? 0,
    replies: t.conversation_count ?? t.reply_count ?? 0,
    ...(t.isEdited ? { edited: true } : {}),
  }
}

/** Fetch a post by its address or id. THROWS on a post that is gone or private (the
 *  endpoint answers a tombstone or nothing), and on any shape not drawn yet. */
export async function fetchTweet(urlOrId: string): Promise<Tweet> {
  const id = tweetId(urlOrId)
  // The endpoint wants a token derived from the id; this is the widget's own formula.
  const token = ((Number(id) / 1e15) * Math.PI)
    .toString(36)
    .replace(/(0+|\.)/g, "")
  const res = await fetch(
    `https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=en&token=${token}`,
  )
  if (!res.ok) throw new Error(`tweet ${id}: HTTP ${res.status}`)
  const raw = (await res.json()) as RawTweet
  if (raw.__typename !== "Tweet")
    throw new Error(
      `tweet ${id}: ${raw.__typename ?? "no post"} (deleted or private)`,
    )
  return fromRaw(raw)
}