telegram-chat
ui · paida Telegram chat drawn from its messages and a playhead, played like a story
npx shadcn add @ag/telegram-chatA 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
{
"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-runYour installed source stays unchanged until you update it. How updates work
- New: a quote among a message's runs,
{ quote: ChatInline[] }inrich, drawn as Telegram draws a blockquote, in the sender's colour.ChatInlinenames 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 fromtelegram-chat.cssto utilities in the component: a rule of yours on.tgchat-emojior.tgchat-photothat set a size now competes with utilities.
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
"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'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's real output for these links; the
follow-up answer is illustrative.
</p>
</div>
)
}



