Telegram
A Telegram client with the chat list beside a bot answering in the open chat.
Customize
import {Scene, Video} from "odori";
import {Telegram} from "../components/telegram/telegram";
<Video>
<Scene id="telegram" duration="9s">
<Telegram
title="Odori"
status="bot"
thinkingFrames={30}
charactersPerSecond={34}
/>
</Scene>
</Video>Installation
pnpm odori add component telegramnpx odori add component telegrambunx odori add component telegramEdit the source in videos/components/telegram/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/telegram/telegram.tsx
import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from "odori";
import type {ReactNode} from "react";
export type TelegramBubble = {from: "them" | "you"; body: string; time?: string};
export type TelegramChat = {name: string; preview: string; time?: string; unread?: number};
export type TelegramProps = {
/** The chat list down the left. The first is the open conversation. */
chats?: TelegramChat[];
/** The chat title in the bar. */
title?: string;
/** What sits under the title: a member count, or "bot". */
status?: string;
/** Bubbles already in the chat. */
bubbles?: TelegramBubble[];
/** The reply, typed in after the header says it is typing. */
reply?: TelegramBubble;
thinkingFrames?: number;
charactersPerSecond?: number;
};
/**
* Telegram's night theme, read from the web client's own theme map: the
* accent is a violet rather than the blue the day theme uses, and the
* surface a chat list sits on is a step lighter than the chat behind it.
*/
const RAIL = "#212121";
const PAGE = "#181818";
const THEM = "#212121";
const YOU = "#8774E1";
const INK = "#FFFFFF";
const FAINT = "#AAAAAA";
const ACCENT = "#8774E1";
const EDGE = "#0F0F0F";
const FIELD = "#181818";
/** Telegram sets its interface in Roboto. */
const FONT = '"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", sans-serif';
/** The client's own message metrics: 16px on 1.3125, timestamps four down. */
const LINE = 1.3125;
/** The seven gradients Telegram assigns an avatar that has no photo. */
const PEER_GRADIENTS: [string, string][] = [
["#FF845E", "#D45246"],
["#FEBB5B", "#F68136"],
["#B694F9", "#6C61DF"],
["#9AD164", "#46BA43"],
["#53EDD6", "#28C9B7"],
["#5CAFFA", "#408ACF"],
["#FF8AAC", "#D95574"],
];
/**
* A peer's colour, picked the way Telegram picks it.
*
* Telegram keys the gradient off the peer id modulo seven. There is no peer
* id here, so the name stands in: the point is that the same name always
* lands on the same colour, which is what makes a chat list look settled
* rather than randomly painted.
*/
const peerGradient = (name: string): [string, string] => {
let hash = 0;
for (const character of name) hash = (hash * 31 + character.codePointAt(0)!) >>> 0;
return PEER_GRADIENTS[hash % PEER_GRADIENTS.length];
};
/** Telegram shows two initials where a name has two words, otherwise one. */
const initials = (name: string) => {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "";
if (words.length === 1) return words[0].slice(0, 1).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
};
/**
* A Telegram client with a bot answering.
*
* Bubbles are the whole language, so they behave like bubbles: each scales up
* from the corner it is anchored to rather than fading, which is what a
* messenger does and what makes a still frame read as a phone. The chat list
* beside it is what makes it a client rather than a widget, and the header is
* where a messenger reports that the other side is typing.
*/
export const Telegram = ({
chats = [
{name: "Odori", preview: "Yesterday reused cached chunks…", time: "09:39", unread: 1},
{name: "John Doe", preview: "sounds good", time: "09:12"},
{name: "Release notes", preview: "0.0.3 is out", time: "Yesterday"},
],
title = "Odori",
status = "bot",
bubbles = [{from: "you", body: "why is the nightly export bigger than yesterday?", time: "09:38"}],
reply = {
from: "them",
body: "Yesterday reused cached chunks. Today re-encoded at studio quality after the toolchain pin changed.",
time: "09:39",
},
thinkingFrames = 30,
charactersPerSecond = 34,
}: TelegramProps) => {
const frame = useFrame();
const {fps} = useVideo();
const scale = useDesignScale();
const px = (value: number) => value * scale;
/* The typing belongs to the person at the keyboard. They compose the last
bubble in the composer and send it; the bot's answer arrives whole. */
const last = bubbles[bubbles.length - 1];
const earlier = bubbles.slice(0, -1);
const first = 18;
const step = 18;
const typeFrom = first + earlier.length * step;
const typed = useTyping(last?.body ?? "", {from: typeFrom, charactersPerSecond, chunk: 2});
const sendAt = typeFrom + typingFrames(last?.body ?? "", {charactersPerSecond}) + 10;
const workingAt = sendAt + 12;
const replyAt = workingAt + thinkingFrames;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const landed = spring({frame, fps, delayInFrames: replyAt, stiffness: 170, damping: 15});
const sent = frame >= sendAt;
const working = frame >= workingAt && frame < replyAt;
const bubble = (item: TelegramBubble, grown: number, body: ReactNode, key?: string) => {
const mine = item.from === "you";
return (
<div
key={key}
style={{
display: "flex",
justifyContent: mine ? "flex-end" : "flex-start",
opacity: grown,
padding: `${px(6)}px ${px(26)}px`,
}}
>
<div
style={{
background: mine ? YOU : THEM,
borderBottomLeftRadius: mine ? px(16) : px(6),
borderBottomRightRadius: mine ? px(6) : px(16),
borderRadius: px(16),
color: INK,
fontSize: px(22),
lineHeight: LINE,
maxWidth: "70%",
padding: `${px(11)}px ${px(16)}px ${px(9)}px`,
position: "relative",
transform: `scale(${0.85 + grown * 0.15})`,
transformOrigin: mine ? "100% 100%" : "0% 100%",
}}
>
{body}
{item.time ? (
<>
{/* A spacer on the last line, so the text wraps around the
time instead of running underneath it. */}
<span style={{display: "inline-block", height: px(1), width: px(64)}} />
<span
style={{
bottom: px(8),
color: mine ? "rgba(255,255,255,0.6)" : FAINT,
fontSize: px(16),
position: "absolute",
right: px(16),
}}
>
{item.time}
</span>
</>
) : null}
</div>
</div>
);
};
return (
<Fill style={{background: "#000000", padding: px(60)}}>
<div
style={{
borderRadius: px(14),
display: "flex",
fontFamily: FONT,
height: "100%",
opacity: enter,
overflow: "hidden",
transform: `translateY(${(1 - enter) * px(12)}px)`,
width: "100%",
}}
>
{/* The chat list. */}
<div style={{background: RAIL, borderRight: `${px(1)}px solid ${EDGE}`, flex: "none", width: px(400)}}>
<div
style={{
background: FIELD,
borderRadius: px(999),
color: FAINT,
fontSize: px(19),
margin: `${px(20)}px ${px(18)}px`,
padding: `${px(11)}px ${px(18)}px`,
}}
>
Search
</div>
{chats.map((chat, index) => (
<div
key={chat.name}
style={{
alignItems: "center",
background: index === 0 ? ACCENT : "transparent",
display: "flex",
gap: px(14),
padding: `${px(14)}px ${px(18)}px`,
}}
>
<span
style={{
alignItems: "center",
background: `linear-gradient(${peerGradient(chat.name)[0]}, ${peerGradient(chat.name)[1]})`,
borderRadius: px(999),
color: "#FFFFFF",
display: "flex",
flex: "none",
fontSize: px(20),
fontWeight: 500,
height: px(54),
justifyContent: "center",
width: px(54),
}}
>
{initials(chat.name)}
</span>
<div style={{minWidth: 0, width: "100%"}}>
<div style={{alignItems: "baseline", display: "flex", gap: px(10)}}>
<span style={{color: INK, fontSize: px(20), fontWeight: 600}}>{chat.name}</span>
<span style={{color: index === 0 ? "rgba(255,255,255,0.75)" : FAINT, fontSize: px(16), marginLeft: "auto"}}>
{chat.time}
</span>
</div>
<div style={{alignItems: "center", display: "flex", gap: px(8), marginTop: px(3)}}>
<span
style={{
color: index === 0 ? "rgba(255,255,255,0.75)" : FAINT,
fontSize: px(18),
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{chat.preview}
</span>
{chat.unread ? (
<span
style={{
background: "#FFFFFF",
borderRadius: px(999),
color: ACCENT,
fontSize: px(15),
fontWeight: 700,
marginLeft: "auto",
padding: `${px(1)}px ${px(8)}px`,
}}
>
{chat.unread}
</span>
) : null}
</div>
</div>
</div>
))}
</div>
{/* The chat. */}
<div style={{background: PAGE, display: "flex", flex: 1, flexDirection: "column", minWidth: 0}}>
<div style={{alignItems: "center", background: RAIL, borderBottom: `${px(1)}px solid ${EDGE}`, display: "flex", gap: px(14), padding: `${px(16)}px ${px(24)}px`}}>
<span
style={{
alignItems: "center",
background: `linear-gradient(${peerGradient(title)[0]}, ${peerGradient(title)[1]})`,
borderRadius: px(999),
color: "#FFFFFF",
display: "flex",
fontSize: px(19),
fontWeight: 500,
height: px(50),
justifyContent: "center",
width: px(50),
}}
>
{initials(title)}
</span>
<div>
<div style={{color: INK, fontSize: px(22), fontWeight: 600}}>{title}</div>
<div style={{color: working ? ACCENT : FAINT, fontSize: px(18), marginTop: px(2)}}>
{working ? "typing…" : status}
</div>
</div>
</div>
<div style={{display: "flex", flex: 1, flexDirection: "column", justifyContent: "flex-end", paddingBottom: px(14)}}>
{earlier.map((item, index) =>
bubble(
item,
spring({frame, fps, delayInFrames: first + index * step, stiffness: 170, damping: 15}),
item.body,
`${item.from}-${index}`,
),
)}
{last && sent
? bubble(
last,
spring({frame, fps, delayInFrames: sendAt, stiffness: 170, damping: 15}),
last.body,
"sent",
)
: null}
{frame >= replyAt
? bubble(
reply,
landed,
reply.body,
)
: null}
</div>
{/* The composer, with the controls Telegram keeps in it: a smiley
on the left, a paperclip and a mic on the right. */}
<div
style={{
alignItems: "center",
background: RAIL,
borderTop: `${px(1)}px solid ${EDGE}`,
color: FAINT,
display: "flex",
fontSize: px(22),
gap: px(16),
padding: `${px(14)}px ${px(20)}px`,
}}
>
<svg viewBox="0 0 24 24" width={px(26)} height={px(26)} fill="none" stroke="currentColor" strokeWidth={1.7} style={{flex: "none"}}>
<circle cx="12" cy="12" r="9" />
<circle cx="9" cy="10" r="1.1" fill="currentColor" stroke="none" />
<circle cx="15" cy="10" r="1.1" fill="currentColor" stroke="none" />
<path d="M8.5 14a4.5 4.5 0 0 0 7 0" strokeLinecap="round" />
</svg>
<span style={{flex: 1, minWidth: 0}}>
{sent ? (
"Message"
) : (
<span style={{color: INK}}>
{typed.text}
{typed.caret ? (
<span
style={{
background: INK,
display: "inline-block",
height: px(21),
marginLeft: px(2),
transform: `translateY(${px(3)}px)`,
width: px(2),
}}
/>
) : null}
</span>
)}
</span>
<svg viewBox="0 0 24 24" width={px(26)} height={px(26)} fill="none" stroke="currentColor" strokeWidth={1.7} style={{flex: "none"}}>
<path d="M20 11.5 12.5 19a4.6 4.6 0 0 1-6.5-6.5l7.8-7.8a3 3 0 0 1 4.3 4.3l-7.8 7.8a1.5 1.5 0 0 1-2.1-2.1l7.2-7.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<svg viewBox="0 0 24 24" width={px(26)} height={px(26)} fill="none" stroke="currentColor" strokeWidth={1.7} style={{flex: "none"}}>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3" strokeLinecap="round" />
</svg>
</div>
</div>
</div>
</Fill>
);
};
videos/components/telegram/telegram.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Telegram} from "./telegram";
export default defineComponentPreview({
title: "Telegram",
category: "Products",
description: "A Telegram client with the chat list beside a bot answering in the open chat.",
component: Telegram,
canvas: {width: 1920, height: 1080, duration: "9s"},
controls: {
title: {type: "text", defaultValue: "Odori", maxLength: 24},
status: {type: "text", defaultValue: "bot", maxLength: 24},
thinkingFrames: {type: "number", defaultValue: 30, min: 0, max: 90, step: 2},
charactersPerSecond: {type: "number", defaultValue: 34, min: 10, max: 80, step: 1},
},
examples: [
{name: "Default", props: {}},
{
name: "Short exchange",
props: {
bubbles: [{from: "you", body: "status?", time: "09:41"}],
reply: {from: "them", body: "All green. Export finished 4 minutes ago.", time: "09:41"},
thinkingFrames: 18,
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Telegram} from "../components/telegram/telegram";
<Video>
<Scene id="telegram" duration="9s">
<Telegram
title="Odori"
status="bot"
thinkingFrames={30}
charactersPerSecond={34}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Products
- Recommended duration
- 270 frames · 9s
- Minimum duration
- 150 frames · 5s
- Entrance
- 14 frames
- Exit
- 12 frames
- Reduced motion
- bubbles placed, no grow or typing line
- Requires
- sans font
- Content limits
- title ≤ 24, status ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
chatsThe chat list down the left. The first is the open conversation. | TelegramChat[] | — | — |
titleThe chat title in the bar. | string | "Odori" | — |
statusWhat sits under the title: a member count, or "bot". | string | "bot" | — |
bubblesBubbles already in the chat. | TelegramBubble[] | [{from: "you" | — |
replyThe reply, typed in after the header says it is typing. | TelegramBubble | — | — |
thinkingFrames | number | 30 | — |
charactersPerSecond | number | 34 | — |
Related components
All componentsDiscordA Discord server where a bot answers in a channel, quoting the message it replies to.Dock barA macOS-style dock where one app launches with a bounce.GitHub commentAn agent response on a GitHub pull request.GitHub pull requestA pull request whose checks resolve one at a time and unlock the merge.iMessageMessages with the conversation list beside the thread, the reply growing from the typing bubble.LinearA Linear workspace where an agent comments on an issue and moves it to done.