Discord
A Discord server where a bot answers in a channel, quoting the message it replies to.
Customize
import {Scene, Video} from "odori";
import {Discord} from "../components/discord/discord";
<Video>
<Scene id="discord" duration="9s">
<Discord
server="Odori"
channel="support"
topic="Render questions and bug reports"
thinkingFrames={30}
charactersPerSecond={34}
/>
</Scene>
</Video>Installation
pnpm odori add component discordnpx odori add component discordbunx odori add component discordEdit the source in videos/components/discord/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/discord/discord.tsx
import {Easing, Fill, interpolate, spring, typingFrames, useBrand, useDesignScale, useFrame, useTyping, useVideo} from "odori";
import type {ReactNode} from "react";
export type DiscordPost = {
author: string;
body: string;
time?: string;
/** Marks the sender as an application. */
app?: boolean;
/** The author's role colour, the way a server colours a name. */
color?: string;
};
export type DiscordProps = {
/** The server name over the channel list. */
server?: string;
/** Channels under the text category. */
channels?: string[];
/** The channel being read. */
channel?: string;
/** The channel topic, beside the name in the header. */
topic?: string;
/** Messages already in the channel. */
history?: DiscordPost[];
/** The message that gets answered. */
ask?: DiscordPost;
/** The bot's reply. It quotes the message it answers. */
reply?: DiscordPost & {to?: string};
/** Names in the members list. Hidden when empty. */
members?: string[];
thinkingFrames?: number;
charactersPerSecond?: number;
};
/** Discord's dark tokens, the same set the desktop and mobile apps share. */
/** Discord's shipped dark theme, read from the client. */
const RAIL = "#121214";
const SIDEBAR = "#1A1A1E";
const CHAT = "#202024";
const COMPOSER = "#2B2B31";
const ACTIVE = "#393A41";
const INK = "#EFEFF1";
const STRONG = "#FBFBFB";
const MUTED = "#96979E";
/** Discord inks a channel name a step down from muted: --channels-default. */
const CHANNEL = "#81828A";
const META = "#96979E";
const BLURPLE = "#5865F2";
const FONT = '"gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif';
const ROLES = ["#5865F2", "#EB459E", "#57F287", "#FEE75C"];
/**
* A Discord server with a bot answering in a channel.
*
* Discord is three columns and the whole app is the recognition: the server
* rail, the channel list with one channel lit, and the members list on the
* right. The reply quotes the message it answers, which is the detail that
* lets a single frame carry both the question and the answer without a cut.
*/
export const Discord = ({
server = "Odori",
channels = ["general", "support", "releases", "showcase"],
channel = "support",
topic = "Render questions and bug reports",
history = [{author: "janedoe", body: "nightly finished, artifacts are up", time: "9:31 AM", color: ROLES[2]}],
ask = {author: "johndoe", body: "the nightly render is 40MB heavier than yesterday, anyone seen that?", time: "9:38 AM"},
reply = {
author: "Odori",
app: true,
to: "johndoe",
body: "Same commit. Yesterday reused cached chunks; today re-encoded at studio quality after the toolchain pin.",
time: "9:39 AM",
color: BLURPLE,
},
members = ["Odori", "johndoe", "janedoe", "samdoe"],
thinkingFrames = 30,
charactersPerSecond = 34,
}: DiscordProps) => {
const frame = useFrame();
const {fps} = useVideo();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
/* The typing is the person's, not the bot's: they compose in the box and
press send, and the bot's answer is simply there when it arrives. */
const typeFrom = 14;
const askAt = typeFrom + typingFrames(ask.body, {charactersPerSecond}) + 10;
const workingAt = askAt + 20;
const replyAt = workingAt + thinkingFrames;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const typed = useTyping(ask.body, {from: typeFrom, charactersPerSecond, chunk: 2});
const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});
const replied = spring({frame, fps, delayInFrames: replyAt, stiffness: 150, damping: 16});
const sent = frame >= askAt;
const working = frame >= workingAt && frame < replyAt;
/**
* Discord's proportions, not approximations of them: the avatar is 2.5x
* the message text and the gutter matches it, which is what makes the
* avatar hang past the name into the first line rather than sitting
* centred on the name.
*/
/**
* Discord's default avatar is a silhouette on a flat colour, never an
* initial. An app keeps its own mark, which is how the eye separates the
* bot from the people in one glance.
*/
const avatar = (name: string, color: string | undefined, size = 52, app = false) => (
<span
style={{
alignItems: "center",
background: color ?? ROLES[name.length % ROLES.length],
borderRadius: px(999),
color: "#FFFFFF",
display: "flex",
flex: "none",
fontSize: px(size * 0.42),
fontWeight: 700,
height: px(size),
justifyContent: "center",
width: px(size),
}}
>
{app ? (
name.slice(0, 1).toUpperCase()
) : (
/* The silhouette Discord draws for an account with no picture:
a head and shoulders, centred, at about half the circle. */
<svg viewBox="0 0 24 24" width={px(size * 0.62)} height={px(size * 0.62)} fill="#FFFFFF">
<circle cx="12" cy="9" r="4" />
<path d="M4.6 20a7.6 7.6 0 0 1 14.8 0z" />
</svg>
)}
</span>
);
const post = (item: DiscordPost, landed: number, body: ReactNode, key?: string) => (
<div
key={key}
style={{
display: "flex",
gap: px(21),
opacity: landed,
padding: `${px(9)}px ${px(22)}px`,
transform: `translateY(${(1 - landed) * px(8)}px)`,
}}
>
{avatar(item.author, item.color, 52, item.app)}
<div style={{minWidth: 0}}>
<div style={{alignItems: "baseline", display: "flex", gap: px(10)}}>
<span style={{color: item.color ?? ROLES[item.author.length % ROLES.length], fontSize: px(21), fontWeight: 600}}>
{item.author}
</span>
{item.app ? (
<span
style={{
background: BLURPLE,
borderRadius: px(4),
color: "#FFFFFF",
fontSize: px(13),
fontWeight: 600,
padding: `${px(1)}px ${px(6)}px`,
}}
>
APP
</span>
) : null}
{item.time ? <span style={{color: MUTED, fontSize: px(16)}}>{item.time}</span> : null}
</div>
<div style={{color: INK, fontSize: px(21), lineHeight: 1.45, marginTop: px(3)}}>{body}</div>
</div>
</div>
);
const dots = (
<span style={{alignItems: "center", display: "inline-flex", gap: px(7), height: px(26)}}>
{[0, 1, 2].map((dot) => (
<span
key={dot}
style={{
background: MUTED,
borderRadius: px(999),
height: px(9),
opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),
width: px(9),
}}
/>
))}
</span>
);
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 server rail. */}
<div
style={{
alignItems: "center",
background: RAIL,
display: "flex",
flex: "none",
flexDirection: "column",
gap: px(14),
padding: `${px(16)}px 0`,
width: px(84),
}}
>
<span
style={{
alignItems: "center",
background: BLURPLE,
borderRadius: px(18),
color: "#FFFFFF",
display: "flex",
fontSize: px(22),
fontWeight: 700,
height: px(52),
justifyContent: "center",
width: px(52),
}}
>
{server.slice(0, 1).toUpperCase()}
</span>
<span style={{background: "#35373C", height: px(2), width: px(34)}} />
{[0, 1].map((item) => (
<span key={item} style={{background: SIDEBAR, borderRadius: px(999), height: px(52), width: px(52)}} />
))}
</div>
{/* The channel list. */}
<div style={{background: SIDEBAR, flex: "none", width: px(300)}}>
<div
style={{
borderBottom: `${px(1)}px solid ${RAIL}`,
color: STRONG,
fontSize: px(21),
fontWeight: 700,
padding: `${px(20)}px ${px(20)}px`,
}}
>
{server}
</div>
<div
style={{
color: MUTED,
fontSize: px(14),
fontWeight: 700,
letterSpacing: "0.04em",
padding: `${px(20)}px ${px(20)}px ${px(8)}px`,
}}
>
TEXT CHANNELS
</div>
{channels.map((name) => (
<div
key={name}
style={{
alignItems: "center",
background: name === channel ? ACTIVE : "transparent",
borderRadius: px(6),
color: name === channel ? STRONG : CHANNEL,
display: "flex",
fontSize: px(19),
fontWeight: name === channel ? 600 : 400,
gap: px(8),
margin: `${px(2)}px ${px(10)}px`,
padding: `${px(8)}px ${px(10)}px`,
}}
>
<span style={{color: name === channel ? STRONG : CHANNEL, fontSize: px(21)}}>#</span>
{name}
</div>
))}
</div>
{/* The channel. */}
<div style={{background: CHAT, display: "flex", flex: 1, flexDirection: "column", minWidth: 0}}>
<div
style={{
alignItems: "center",
borderBottom: `${px(1)}px solid rgba(0,0,0,0.2)`,
display: "flex",
gap: px(14),
padding: `${px(18)}px ${px(22)}px`,
}}
>
<span style={{color: MUTED, fontSize: px(24)}}>#</span>
<span style={{color: STRONG, fontSize: px(22), fontWeight: 700}}>{channel}</span>
<span style={{background: "#3F4147", height: px(22), width: px(1)}} />
<span style={{color: MUTED, fontSize: px(17)}}>{topic}</span>
</div>
<div style={{display: "flex", flex: 1, flexDirection: "column", justifyContent: "flex-end", paddingBottom: px(10)}}>
{history.map((item, index) => post(item, 1, item.body, `${item.author}-${index}`))}
{post(ask, asked, ask.body)}
{frame >= workingAt ? (
<div style={{opacity: working ? 1 : replied}}>
{reply.to && !working ? (
<div
style={{
alignItems: "center",
color: MUTED,
display: "flex",
fontSize: px(16),
gap: px(8),
paddingLeft: px(73),
}}
>
<span
style={{
borderLeft: `${px(2)}px solid #4E5058`,
borderTop: `${px(2)}px solid #4E5058`,
borderTopLeftRadius: px(8),
height: px(10),
marginRight: px(4),
width: px(24),
}}
/>
{avatar(reply.to, ask.color, 20)}
<span style={{color: ROLES[0]}}>@{reply.to}</span>
<span style={{overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>{ask.body}</span>
</div>
) : null}
{post(
reply,
1,
working ? dots : reply.body,
)}
</div>
) : null}
</div>
{/* The composer, with the controls Discord keeps in it: a plus on
the left for uploads, and the gift, GIF and emoji row on the
right. Without them the box reads as a text field, not Discord. */}
<div
style={{
alignItems: "center",
background: COMPOSER,
borderRadius: px(10),
display: "flex",
gap: px(16),
margin: `0 ${px(22)}px ${px(20)}px`,
padding: `${px(12)}px ${px(18)}px`,
}}
>
{/* A drawn plus, not the character: a text glyph carries its
own ascent and descent and sits low in a round button. */}
<span
style={{
alignItems: "center",
background: MUTED,
borderRadius: px(999),
color: COMPOSER,
display: "flex",
flex: "none",
height: px(26),
justifyContent: "center",
width: px(26),
}}
>
<svg viewBox="0 0 20 20" width={px(18)} height={px(18)} fill="currentColor">
<path d="M10.75 3.25a.75.75 0 0 0-1.5 0v6h-6a.75.75 0 0 0 0 1.5h6v6a.75.75 0 0 0 1.5 0v-6h6a.75.75 0 0 0 0-1.5h-6z" />
</svg>
</span>
<span style={{color: sent ? MUTED : INK, flex: 1, fontSize: px(19), minWidth: 0}}>
{sent ? (
`Message #${channel}`
) : (
<>
{typed.text}
{typed.caret ? (
<span
style={{
background: INK,
display: "inline-block",
height: px(20),
marginLeft: px(2),
transform: `translateY(${px(3)}px)`,
width: px(2),
}}
/>
) : null}
</>
)}
</span>
<span style={{alignItems: "center", color: MUTED, display: "flex", flex: "none", gap: px(14)}}>
{/* Gift. */}
<svg viewBox="0 0 24 24" width={px(22)} height={px(22)} fill="none" stroke="currentColor" strokeWidth={1.8}>
<path d="M4 11h16v9H4zM3 7.5h18V11H3zM12 7.5V20" strokeLinejoin="round" />
<path d="M12 7.5S10.5 4 8.5 4a2 2 0 0 0 0 3.5zM12 7.5S13.5 4 15.5 4a2 2 0 0 1 0 3.5z" strokeLinejoin="round" />
</svg>
{/* GIF. */}
<span
style={{
border: `${px(1.6)}px solid currentColor`,
borderRadius: px(4),
fontSize: px(12),
fontWeight: 700,
letterSpacing: "0.02em",
padding: `${px(1)}px ${px(4)}px`,
}}
>
GIF
</span>
{/* Emoji. */}
<svg viewBox="0 0 24 24" width={px(22)} height={px(22)} fill="none" stroke="currentColor" strokeWidth={1.8}>
<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 14.5a4.5 4.5 0 0 0 7 0" strokeLinecap="round" />
</svg>
</span>
</div>
</div>
{/* The members list. */}
{members.length > 0 ? (
<div style={{background: SIDEBAR, flex: "none", padding: `${px(20)}px ${px(16)}px`, width: px(260)}}>
<div style={{color: MUTED, fontSize: px(14), fontWeight: 700, letterSpacing: "0.04em", paddingLeft: px(8)}}>
ONLINE — {members.length}
</div>
{members.map((name, index) => (
<div
key={name}
style={{alignItems: "center", display: "flex", gap: px(12), padding: `${px(9)}px ${px(8)}px`}}
>
{avatar(name, index === 0 ? BLURPLE : ROLES[name.length % ROLES.length], 36, index === 0)}
<span style={{color: index === 0 ? BLURPLE : META, fontSize: px(19)}}>{name}</span>
{index === 0 ? (
<span
style={{
background: BLURPLE,
borderRadius: px(4),
color: "#FFFFFF",
fontSize: px(12),
fontWeight: 600,
padding: `${px(1)}px ${px(5)}px`,
}}
>
APP
</span>
) : null}
</div>
))}
</div>
) : null}
</div>
</Fill>
);
};
videos/components/discord/discord.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Discord} from "./discord";
export default defineComponentPreview({
title: "Discord",
category: "Products",
description: "A Discord server where a bot answers in a channel, quoting the message it replies to.",
component: Discord,
canvas: {width: 1920, height: 1080, duration: "9s"},
controls: {
server: {type: "text", defaultValue: "Odori", maxLength: 24},
channel: {type: "text", defaultValue: "support", maxLength: 24},
topic: {type: "text", defaultValue: "Render questions and bug reports", maxLength: 60},
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: "No members list", props: {members: []}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Discord} from "../components/discord/discord";
<Video>
<Scene id="discord" duration="9s">
<Discord
server="Odori"
channel="support"
topic="Render questions and bug reports"
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
- messages placed, no working indicator
- Requires
- sans font
- Content limits
- server ≤ 24, channel ≤ 24, topic ≤ 60
Props
| Prop | Type | Default | Required |
|---|---|---|---|
serverThe server name over the channel list. | string | "Odori" | — |
channelsChannels under the text category. | string[] | ["general" | — |
channelThe channel being read. | string | "support" | — |
topicThe channel topic, beside the name in the header. | string | "Render questions and bug reports" | — |
historyMessages already in the channel. | DiscordPost[] | [{author: "janedoe" | — |
askThe message that gets answered. | DiscordPost | {author: "johndoe" | — |
replyThe bot's reply. It quotes the message it answers. | DiscordPost & {to?: string} | — | — |
membersNames in the members list. Hidden when empty. | string[] | ["Odori" | — |
thinkingFrames | number | 30 | — |
charactersPerSecond | number | 34 | — |
Related components
All componentsDock 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.SlackA Slack channel where a question is answered by an app in its thread.