← ui

telegram-chat

ui · paid

a Telegram chat drawn from its messages and a playhead, played like a story

npx shadcn add @ag/telegram-chat

A paid item: its source installs with a licence key, kept in .env.local as AG_LICENCE_KEY and sent from components.json. The source stays in your project and out of any public repository. The licence

components.json
{
  "registries": {
    "@ag": {
      "url": "https://ui.adriangalilea.com/r/{name}.json",
      "headers": { "Authorization": "Bearer ${AG_LICENCE_KEY}" }
    }
  }
}
Already installed? Preview an update
npx shadcn add @ag/telegram-chat --dry-run

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

Latest changes · 2026-10-17
  • New: a quote among a message's runs, { quote: ChatInline[] } in rich, drawn as Telegram draws a blockquote, in the sender's colour. ChatInline names the text run. parseTelegramText (telegram-chat-post) reads a post's <blockquote> into one; it used to refuse the post.
  • Fix: an italic run is slanted where the page's face has no italic (Geist has none); it drew upright.
  • Avatars, emoji and photos are Image. An avatar's picture fills its slot over the slot's tone while it loads. The emoji's and the photo's sizes moved from telegram-chat.css to utilities in the component: a rule of yours on .tgchat-emoji or .tgchat-photo that set a size now competes with utilities.
Updated 2026-10-17 · release notes

shelf social & links

uses clip, person, chat, image, playback, web-preview, card-link, device-frame, material, scrims, video

used by telegram-summary

the demo above, verbatim

telegram-chat.demo.tsx
"use client"

import * as React from "react"
import { trace, useDebug } from "@/app/debug"
import { Sample } from "@/app/samples"
import { chapterSpan } from "@/registry/base-nova/lib/clip"
import {
  postScript,
  type TelegramPost,
} from "@/registry/base-nova/lib/telegram-chat-post"
import { Button } from "@/registry/base-nova/ui/button"
import { Playback } from "@/registry/base-nova/ui/playback"
import { Player } from "@/registry/base-nova/ui/player"
import { Act, ScrollStage, useAct } from "@/registry/base-nova/ui/scroll-stage"
import {
  type ChatProfile,
  type ChatScript,
  chatClip,
  storyEnd,
  TelegramChat,
} from "@/registry/base-nova/ui/telegram-chat"

// #region people
/** THE ACCOUNTS, ONCE. Define a person or a bot as a profile and use it anywhere a chat
 *  names someone: `from: ADRIAN` in a group, `chatName: ADRIAN` for a private chat with
 *  him, the bot's DM, the afterlife. The label, the mini avatar, the header, the handle
 *  all come from the one definition. A script can also carry a `people` map and name
 *  them by key (`from: "melon"`), which is what the story below does. */
const ADRIAN: ChatProfile = {
  name: "Adrian",
  handle: "@adriangalilea",
  avatar: "/adriangalilea.jpg",
  avatarVideo: "/adriangalilea.mp4",
}
const XTLDR: ChatProfile = {
  name: "xtldr",
  handle: "@xtldrbot",
  avatar: "/xtldr-bot.jpg",
  bot: true,
}
const PEOPLE: Record<string, ChatProfile> = {
  adrian: ADRIAN,
  melon: { name: "Melon", handle: "@melonflip" },
  xtldr: XTLDR,
}
// #endregion

// #region post
/** A PUBLIC CHANNEL POST, as data: `mise telegram-post https://t.me/jardindigital/236`
 *  printed this, and its two pictures were downloaded beside the page (Telegram's CDN
 *  paths are signed and expire). `postScript` makes it a chat of one message, so the
 *  bubble that draws every chat on this page draws the post too. */
const POST: TelegramPost = {
  url: "https://t.me/jardindigital/236",
  channel: {
    name: "Jardin Digital",
    handle: "@jardindigital",
    avatar: "/telegram-jardindigital.jpg",
  },
  date: "2024-07-22T22:27:05+00:00",
  edited: true,
  views: "59",
  photos: [{ src: "/telegram-post-236.jpg", width: 800, height: 121 }],
  body: [
    { text: "💡" },
    { text: "AI as an API", marks: ["bold"] },
    { text: " to accelerate development" },
    { text: ".", marks: ["bold"] },
    {
      text: "\n\nInstead of using AI to help you code an API:\n\n[USER NEED] -> AI as API -> Validation -> Refine for cost/quality.\n",
    },
    {
      pre: 'def complex_task():\n    """\n    Input: Complex JSON data\n    Instructions: Analyze and produce structured output\n    Output: {\n        "result": "Predicted outcome",\n        "confidence": 0.95\n    }\n    """',
    },
    { text: "\nTraditionally you'd add a " },
    { text: "pass", marks: ["code"] },
    {
      text: " for a function like this or you'd just give up while implementing, now you can let AI be a blackbox that produces the output that you need validated with something like pydantic, using instructor.\n\nVery specific use-cases, but I'm sure everyone misses them still.\n\n(Natural Language Coding)",
    },
  ],
}
// #endregion

// #region formatted
/** A bot's broadcast as Telegram draws its entities: what an admin composing one sees
 *  before it goes out. */
const FORMATTED: ChatScript = {
  kind: "bot",
  chatName: "xtldr",
  people: PEOPLE,
  alt: "a broadcast from xtldr with bold, a link, code and a quote",
  messages: [
    {
      from: "xtldr",
      rich: [
        { text: "New this week", marks: ["bold"] },
        { text: "\nReply " },
        { text: "@xtldrbot es", marks: ["code"] },
        { text: " to a link and the summary comes back in Spanish.\n" },
        {
          quote: [
            { text: "The code, the English name or the language's own: " },
            { text: "es, spanish, español", marks: ["italic"] },
          ],
        },
        { text: "Every language it speaks: " },
        {
          text: "xtldr.untitled.garden",
          href: "https://xtldr.untitled.garden",
        },
      ],
      foot: { time: "18:02" },
    },
  ],
}
// #endregion

// #region story
/** THE BOT'S OWN FLOW, with the bot's own output: a link lands, someone tags the bot,
 *  it reacts 👀 and types, the summary streams with cited timestamps, a follow-up gets
 *  an answer. The summary is @xtldrbot's real transcript for this talk; the citations
 *  are its timestamps, deep-linked. */
const TALK = "youtube.com/watch?v=zjkBMFhNj_g"
const SCRIPT: ChatScript = {
  kind: "group",
  chatName: "the garden",
  chatTag: "3 members",
  people: PEOPLE,
  messages: [
    { from: "melon", text: "you have to watch this" },
    {
      from: "melon",
      text: TALK,
      preview: {
        site: "YouTube",
        title: "[1hr Talk] Intro to Large Language Models",
        description: "Andrej Karpathy",
        image: "/xtldr-preview-karpathy.jpg",
      },
      reactions: [{ emoji: "👀", when: "timeline" }],
    },
    {
      from: "me",
      text: "@xtldrbot",
      reply: { from: "Melon", text: TALK },
      typed: true,
    },
    {
      from: "xtldr",
      typing: "xtldr is typing",
      source: TALK,
      blocks: [
        {
          kind: "heading",
          text: 'LLMs: The 140GB "Zip File" of the Internet',
          emoji: "🤖",
        },
        {
          kind: "item",
          text: "An LLM is just 2 files: a 140GB parameters file (70B numbers) and ~500 lines of C code to run it.",
          cite: "0:29",
        },
        {
          kind: "item",
          text: "Training is lossy compression of ~10TB of internet text into those weights: a ~100x compression ratio.",
          cite: "4:34",
        },
        { kind: "heading", text: 'The Future: An "LLM OS"', emoji: "🛠️" },
        {
          kind: "item",
          text: "Think of an LLM as the kernel of an emerging operating system, coordinating tools and memory (context window = RAM).",
          cite: "42:33",
        },
        {
          kind: "quote",
          text: "It's kind of like a lossy compression of the internet.",
          by: "Andrej Karpathy",
          cite: "6:02",
        },
      ],
      meta: {
        label: "🔗 YouTube",
        href: `https://${TALK}`,
        time: "⏱️ 59m 48s saved",
      },
      reactions: [
        { emoji: "❤️", count: 2 },
        { emoji: "🔥", count: 1 },
      ],
    },
    {
      from: "me",
      text: "what does he say about hallucinations?",
      typed: true,
    },
    {
      from: "xtldr",
      typing: "xtldr is typing",
      source: TALK,
      blocks: [
        { kind: "heading", text: "Hallucinations", emoji: "💭" },
        {
          kind: "item",
          text: "The model dreams internet documents: a made-up ISBN looks exactly like a real one to it.",
          cite: "17:35",
        },
        {
          kind: "item",
          text: "Tool use is the fix: browse, run code, look it up, rather than recall from weights.",
          cite: "32:03",
        },
      ],
    },
  ],
  alt: "A group chat: a friend drops a talk, the bot is tagged and summarizes it with timestamps, a follow-up gets a cited answer.",
}
/** The chat as a clip: one chapter per message, then its afterlife. Every driver
 *  below plays this same clip; message k starts at `CLIP.chapters[k].start`. */
const CLIP = chatClip(SCRIPT)
/** Where the story ends and its afterlife begins: a chat that opens complete. */
const STORY_END = storyEnd(SCRIPT)
// #endregion

// #region chats
/** THE SAME TWO DEFINITIONS, TWO CHATS. A private chat WITH Adrian: `chatName: ADRIAN`
 *  puts his photo and profile video in the header. A group where Adrian and the bot are
 *  both senders: `from: ADRIAN`, `from: XTLDR` give each their label and mini avatar, the
 *  bot answering the way it does in a group (someone drops a link, it reacts 👀, types,
 *  summarizes). Nothing about either of them was written twice. */
const PEER: ChatScript = {
  kind: "peer",
  chatName: ADRIAN,
  chatTag: "online",
  messages: [
    { from: ADRIAN, text: "the frameless telegram shipped" },
    {
      from: "me",
      text: "finally",
      reactions: [{ emoji: "🔥", when: "timeline" }],
    },
  ],
  alt: "A private chat with Adrian.",
}
const MANAGED: ChatScript = {
  ...PEER,
  chatTag: "last seen 2 hours ago",
  managedBy: XTLDR,
  messages: [
    ...PEER.messages,
    {
      from: ADRIAN,
      text: "My new keyboard.",
      reactions: [{ emoji: "❤️", count: 1, when: "timeline" }],
    },
    { from: ADRIAN, text: "The old split finally gave out." },
    {
      from: "me",
      text: "That looks so comfortable",
      reply: { from: ADRIAN, text: "My new keyboard." },
    },
    { from: "me", text: "I need to try it sometime" },
    {
      from: ADRIAN,
      text: "It took a little practice, but now I can keep my shoulders relaxed while I work.",
      reactions: [{ emoji: "🔥", count: 2, when: "timeline" }],
    },
    {
      from: ADRIAN,
      text: "Come over and try it. Bring the old one too — we might be able to fix it.",
    },
    { from: "me", text: "Saturday?" },
    {
      from: ADRIAN,
      text: "Perfect. Coffee and keyboards.",
      reactions: [{ emoji: "❤️", count: 1, when: "timeline" }],
    },
  ],
  alt: "A private chat with Adrian, with a management bar and compact message reactions.",
}
const RICK = "youtube.com/watch?v=dQw4w9WgXcQ"
const GROUP: ChatScript = {
  kind: "group",
  chatName: "the garden",
  chatTag: "3 members",
  messages: [
    {
      from: ADRIAN,
      text: RICK,
      preview: {
        site: "YouTube",
        title:
          "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)",
        description: "Rick Astley",
        image: "/xtldr-preview-rick.jpg",
      },
      reactions: [{ emoji: "👀", when: "timeline" }],
    },
    {
      from: XTLDR,
      typing: "xtldr is typing",
      source: RICK,
      blocks: [
        {
          kind: "heading",
          text: "Never Gonna Give You Up: The Eternal Rickroll",
          emoji: "🎵",
        },
        {
          kind: "item",
          text: "Rick Astley's 1987 debut single, a synth-pop classic that became the internet's most famous prank.",
        },
        {
          kind: "item",
          text: "The chorus is a list of promises: never give up, never let down, never run around and desert you.",
          cite: "0:43",
        },
      ],
      meta: {
        label: "🔗 YouTube",
        href: `https://${RICK}`,
        time: "⏱️ 3m 33s saved",
      },
      reactions: [{ emoji: "😂", count: 1 }],
    },
  ],
  alt: "A group: Adrian drops a link, the bot reacts, types, and summarizes it.",
}
// #endregion

const WALL = "/tg-pattern.svg"

// #region acts
/** THE SCROLLY: an act index in; a `cue`, `focus` and `crop` out. Each act is a span of
 *  the clip, its messages' chapters (`chapterSpan`), and the chat plays on to the
 *  span's end and waits, so an act never points back at a message the reader already
 *  watched land, and a reader who arrives at act three (two acts at once, or a
 *  reload) gets the act's own beat with everything before it already there. The cue
 *  drives a PLAYER: scroll moves the story act by act, the bar scrubs it anywhere,
 *  and the next scroll picks it up from wherever the bar left it. The effects chain:
 *  the whole phone, then one message lifted with the rest blurred, then the view
 *  zooms onto the answer, the phone growing and the viewport closing down onto it. */
const ACTS: {
  cue: { from: number; to: number }
  /** One message, or an exchange: the question and its answer focus together. */
  focus?: number | number[]
  crop?: string
  wide?: boolean
  head: string
  body: string
}[] = [
  {
    cue: chapterSpan(CLIP, 2),
    head: "a link lands",
    body: "someone drops a talk; you tag the bot. the phone plays to here and waits.",
  },
  {
    cue: chapterSpan(CLIP, 3),
    focus: 3,
    head: "the bot answers",
    body: "the summary streams in and lifts; the rest blur and step back.",
  },
  {
    // Through the last chapter: the afterlife's reactions land in this act too.
    cue: chapterSpan(CLIP, 4, CLIP.chapters.length - 1),
    focus: [4, 5],
    crop: "4 / 3",
    wide: true,
    head: "ask it more",
    body: "a follow-up, a cited answer, and the view zooms onto it: the phone grows, the viewport closes down to the answer, the scroll glides. scroll it once it has settled.",
  },
]

/** The same clip, chaptered by the acts: the bar names and snaps to what the
 *  reader scrolls through, not to every message. */
const ACT_CLIP = {
  duration: CLIP.duration,
  chapters: ACTS.map((a, i) => ({
    start: i === 0 ? 0 : a.cue.from,
    title: a.head,
  })),
}

function Scrolly() {
  const act = useAct()
  const now = ACTS[act] as (typeof ACTS)[number]
  // `?debug` (the corner toggle): the chat prints what its cut decided under itself, and
  // in development every decision, and every act change, is posted to the terminal.
  const [debug] = useDebug()
  React.useEffect(() => {
    if (debug) trace("act", { act, head: now.head })
  }, [debug, act, now.head])
  return (
    <div className="grid h-svh items-center gap-10 lg:grid-cols-[1fr_minmax(0,28rem)]">
      <div className="space-y-6">
        {ACTS.map((a, i) => (
          <Act
            key={a.head}
            index={i}
            className="transition-opacity duration-500"
            style={{ opacity: "calc(0.3 + 0.7 * var(--act-on))" }}
          >
            <h3 className="font-semibold text-lg">{a.head}</h3>
            <p className="text-foreground/70">{a.body}</p>
          </Act>
        ))}
      </div>
      <Player
        clip={ACT_CLIP}
        cue={now.cue}
        label="the story, act by act"
        className={`mx-auto w-full ${now.wide ? "max-w-[28rem]" : "max-w-[22rem]"}`}
      >
        <TelegramChat
          script={SCRIPT}
          wallpaper={WALL}
          theme="dark"
          focus={now.focus}
          crop={now.crop}
          debug={debug ? (t) => trace("cut", t) : false}
          className="w-full"
        />
      </Player>
    </div>
  )
}
// #endregion

export default function Demo() {
  const [framelessReplay, setFramelessReplay] = React.useState(0)
  return (
    <div className="space-y-16">
      <Sample
        name="phone"
        with="people story"
        title="01 · the phone"
        description="both themes"
      >
        {/* The phone is as wide as its box: each box says how wide. */}
        <div className="flex justify-center gap-10">
          <div className="w-89">
            <Player clip={CLIP} label="the story, dark">
              <TelegramChat script={SCRIPT} wallpaper={WALL} theme="dark" />
            </Player>
          </div>
          <div className="w-89 max-lg:hidden">
            <Playback clip={CLIP}>
              <TelegramChat script={SCRIPT} wallpaper={WALL} theme="light" />
            </Playback>
          </div>
        </div>
      </Sample>

      <Sample
        name="small"
        glance="large"
        with="people story"
        title="the phone, small"
        description="every size a share of the phone's width, so a narrow box draws the same story smaller"
      >
        <div className="flex justify-center gap-8">
          <div className="w-52">
            <Player clip={CLIP} label="the story, small">
              <TelegramChat script={SCRIPT} wallpaper={WALL} theme="dark" />
            </Player>
          </div>
          <div className="w-52 max-sm:hidden">
            <Playback clip={CLIP}>
              <TelegramChat script={SCRIPT} wallpaper={WALL} theme="light" />
            </Playback>
          </div>
        </div>
      </Sample>

      <Sample
        name="glass"
        with="people chats"
        title="Floating glass"
        description="title, managed chat, composer, and reactions"
      >
        <div className="flex flex-wrap justify-center gap-10">
          <TelegramChat
            script={MANAGED}
            wallpaper={WALL}
            theme="dark"
            progress={1}
          />
          <TelegramChat
            script={MANAGED}
            wallpaper={WALL}
            theme="light"
            progress={1}
          />
        </div>
      </Sample>

      <Sample
        name="frameless"
        title='02 · frame="none"'
        description="the same script at the width the phone was taking, in a viewport that never reflows the page"
      >
        <div className="flex flex-col items-center gap-4">
          <Playback
            key={framelessReplay}
            clip={CLIP}
            start={CLIP.chapters[4]?.start}
            className="w-full"
          >
            <TelegramChat script={SCRIPT} wallpaper={WALL} frame="none" />
          </Playback>
          <Button
            variant="ghost"
            onClick={() => setFramelessReplay((value) => value + 1)}
          >
            Replay typing and reply
          </Button>
        </div>
      </Sample>

      <Sample
        name="focus"
        title="03 · focus"
        description="one message lifts, the rest blur and step back"
      >
        <div className="flex flex-wrap items-start justify-center gap-10">
          <Playback
            clip={CLIP}
            start={STORY_END}
            className="w-full max-w-[30rem]"
          >
            <TelegramChat
              script={SCRIPT}
              wallpaper={WALL}
              frame="none"
              focus={3}
            />
          </Playback>
          <Playback
            clip={CLIP}
            start={STORY_END}
            className="w-full max-w-[18rem]"
          >
            <TelegramChat
              script={SCRIPT}
              wallpaper={WALL}
              theme="dark"
              focus={2}
            />
          </Playback>
        </div>
      </Sample>

      <Sample
        name="crop"
        title="04 · crop"
        description="the phone at full width, cut in height only, the edges fading only where something is hidden; scrolled to a focus, or to the latest"
      >
        <div className="flex flex-wrap items-start justify-center gap-10">
          <Playback
            clip={CLIP}
            start={STORY_END}
            className="w-full max-w-[24rem]"
          >
            <TelegramChat
              script={SCRIPT}
              wallpaper={WALL}
              theme="dark"
              focus={5}
              crop="4 / 3"
            />
          </Playback>
          <Playback
            clip={CLIP}
            start={STORY_END}
            className="w-full max-w-[20rem]"
          >
            <TelegramChat
              script={SCRIPT}
              wallpaper={WALL}
              theme="light"
              crop="1 / 1"
            />
          </Playback>
        </div>
      </Sample>

      <Sample
        name="people"
        with="people chats"
        title="05 · people"
        description="two profiles, defined once: a private chat with Adrian, and a group where Adrian and the bot are both senders"
      >
        <div className="flex flex-wrap items-start justify-center gap-10">
          <Playback clip={chatClip(PEER)} className="w-full max-w-[18rem]">
            <TelegramChat script={PEER} wallpaper={WALL} theme="dark" />
          </Playback>
          <Playback clip={chatClip(GROUP)} className="w-full max-w-[18rem]">
            <TelegramChat script={GROUP} wallpaper={WALL} theme="dark" />
          </Playback>
        </div>
      </Sample>

      <Sample
        name="scrolly"
        with="acts"
        title="06 · a scrolly"
        description="an act index in; a cue, focus and crop out. scroll plays the chat act by act, the bar scrubs the same story, and the effects chain"
      >
        <ScrollStage
          acts={ACTS.length}
          pace="80svh"
          stacked={
            <div className="space-y-10">
              {ACTS.map((a) => (
                <div key={a.head} className="space-y-4">
                  <h3 className="font-semibold text-lg">{a.head}</h3>
                  <Playback
                    clip={CLIP}
                    cue={a.cue}
                    className="mx-auto w-full max-w-[22rem]"
                  >
                    <TelegramChat
                      script={SCRIPT}
                      wallpaper={WALL}
                      theme="dark"
                      focus={a.focus}
                      crop={a.crop}
                    />
                  </Playback>
                  <p className="text-foreground/70">{a.body}</p>
                </div>
              ))}
            </div>
          }
        >
          <Scrolly />
        </ScrollStage>
      </Sample>

      <Sample
        name="post"
        with="post"
        title="07 · a channel post"
        description="fetched once into the chat's own data, drawn as a still: frameless, frozen, no composer. the page's theme, and pinned dark"
      >
        <div className="flex flex-wrap items-start justify-center gap-10">
          <TelegramChat
            script={postScript(POST, { timeZone: "Europe/Madrid" })}
            href={POST.url}
            wallpaper={WALL}
            frame="none"
            composer={false}
            frozen
            theme="light"
            backdrop
            text="page"
            className="w-full"
          />
          <TelegramChat
            script={postScript(POST, { timeZone: "Europe/Madrid" })}
            href={POST.url}
            wallpaper={WALL}
            frame="none"
            composer={false}
            frozen
            theme="dark"
            backdrop
            text="page"
            className="w-full"
          />
        </div>
      </Sample>

      <Sample
        name="formatted"
        with="people formatted"
        title="08 · formatted text"
        description="a message's own entities as runs: marks, a link, code, a quote in the sender's colour"
      >
        <TelegramChat
          script={FORMATTED}
          wallpaper={WALL}
          frame="none"
          composer={false}
          frozen
          backdrop
          text="page"
          className="w-full"
        />
      </Sample>

      <p className="max-w-prose text-foreground/60 text-sm">
        Reactions are buttons: press one and you count, press again and you
        leave. The emoji are Noto Animated Emoji (Google, Apache 2.0), animated
        WebP, no player; Telegram&apos;s own set lives behind its API and is its
        own. Each is 150-300 KB, lazy, which is why only reactions get them. The
        summaries are @xtldrbot&apos;s real output for these links; the
        follow-up answer is illustrative.
      </p>
    </div>
  )
}