registry/bases/editframe/ui/imessage-chat-flow/index.tsx
"use client";
import { Timegroup } from "@editframe/react";
import { useEffect, useRef, useState } from "react";
import { FramecnIcon, revealedText, useFramecnTheme } from "@/lib/framecn-ui";
import type { FramecnTheme } from "@/lib/framecn-ui";
import { Caret } from "@/components/framecn/caret";
import { TypingIndicator } from "@/components/framecn/typing-indicator";
export interface ImessageMessage {
from: "me" | "them";
text: string;
reaction?: string;
}
export interface ImessageContact {
name: string;
avatar?: string;
}
export interface ImessageChatFlowProps {
messages?: ImessageMessage[];
contact?: ImessageContact;
accentColor?: string;
speed?: number;
fps?: number;
durationInFrames?: number;
theme?: Partial<FramecnTheme>;
className?: string;
}
const MOBILE_WIDTH = 460;
const LEAD_IN = 12;
const FRAMES_PER_CHAR = 2.2;
const MIN_TYPE = 18;
const MAX_TYPE = 86;
const SEND_GAP = 10;
const REVEAL = 14;
const REACT_DELAY = 8;
const REACT_DUR = 14;
const MSG_GAP = 18;
const TYPING_MIN = 34;
const TYPING_MAX = 70;
const TAIL = 28;
const PRESS_WINDOW = 7;
const IMESSAGE_BLUE = "#0a7cff";
const OUTGOING_FG = "#ffffff";
const DEFAULT_MESSAGES: ImessageMessage[] = [
{ from: "me", text: "Hey — ready for the demo?" },
{ from: "them", reaction: "❤️", text: "Yep, pushing it live now" },
{ from: "me", reaction: "👍", text: "Perfect, sending the link over" },
];
const clamp = (value: number, lo: number, hi: number): number =>
Math.max(lo, Math.min(hi, value));
export interface ScheduledMessage {
index: number;
from: "me" | "them";
text: string;
reaction?: string;
presenceStart: number;
typeStart?: number;
sendAt?: number;
typingStart?: number;
revealAt: number;
reactAt?: number;
}
export interface ImessageChatFlowSchedule {
items: ScheduledMessage[];
duration: number;
}
export const imessageChatFlowSchedule = (
messages: ImessageMessage[]
): ImessageChatFlowSchedule => {
const items: ScheduledMessage[] = [];
let cursor = LEAD_IN;
for (const [index, message] of messages.entries()) {
const hasReaction =
message.reaction !== undefined && message.reaction !== "";
if (message.from === "me") {
const typeStart = cursor;
const typeDur = clamp(
Math.round(message.text.length * FRAMES_PER_CHAR),
MIN_TYPE,
MAX_TYPE
);
const sendAt = typeStart + typeDur + SEND_GAP;
const revealAt = sendAt;
const reactAt = hasReaction ? revealAt + REVEAL + REACT_DELAY : undefined;
items.push({
from: "me",
index,
presenceStart: sendAt - 2,
reactAt,
reaction: hasReaction ? message.reaction : undefined,
revealAt,
sendAt,
text: message.text,
typeStart,
});
cursor =
revealAt +
REVEAL +
(hasReaction ? REACT_DELAY + REACT_DUR : 0) +
MSG_GAP;
} else {
const typingStart = cursor;
const typingDur = clamp(
Math.round(message.text.length * FRAMES_PER_CHAR),
TYPING_MIN,
TYPING_MAX
);
const revealAt = typingStart + typingDur;
const reactAt = hasReaction ? revealAt + REVEAL + REACT_DELAY : undefined;
items.push({
from: "them",
index,
presenceStart: typingStart,
reactAt,
reaction: hasReaction ? message.reaction : undefined,
revealAt,
text: message.text,
typingStart,
});
cursor =
revealAt +
REVEAL +
(hasReaction ? REACT_DELAY + REACT_DUR : 0) +
MSG_GAP;
}
}
const duration = Math.max(cursor - MSG_GAP + TAIL, LEAD_IN + TAIL);
return { duration, items };
};
export const imessageChatFlowDuration = (
messages: ImessageMessage[] = DEFAULT_MESSAGES,
speed = 1
): number => {
const raw = imessageChatFlowSchedule(messages).duration;
return Math.ceil(raw / (speed <= 0 ? 1 : speed));
};
export const sendPulse = (items: ScheduledMessage[], eff: number): number => {
let best = 0;
for (const item of items) {
if (item.sendAt === undefined) {
continue;
}
const distance = Math.abs(eff - item.sendAt);
if (distance <= PRESS_WINDOW) {
best = Math.max(best, 1 - distance / PRESS_WINDOW);
}
}
return best;
};
const Avatar = ({
contact,
size,
}: {
contact: ImessageContact;
size: number;
}) => {
const initial = contact.name.trim().charAt(0).toUpperCase();
return (
<div
style={{
alignItems: "center",
background: "linear-gradient(180deg, #b8c0c9 0%, #8e99a4 100%)",
borderRadius: "50%",
color: "#ffffff",
display: "flex",
flexShrink: 0,
fontFamily:
"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif",
fontSize: size * 0.42,
fontWeight: 600,
height: size,
justifyContent: "center",
minWidth: size,
overflow: "hidden",
width: size,
}}
>
{contact.avatar === undefined ? (
initial
) : (
<img
src={contact.avatar}
alt={contact.name}
style={{ height: "100%", objectFit: "cover", width: "100%" }}
/>
)}
</div>
);
};
const BubbleTail = ({
side,
color,
}: {
side: "left" | "right";
color: string;
}) => {
const path =
side === "right"
? "M0 0 C2 8 6 12 12 13 C7 14 1 13 0 8 Z"
: "M13 0 C11 8 7 12 1 13 C6 14 12 13 13 8 Z";
return (
<svg
width={13}
height={14}
viewBox="0 0 13 14"
style={{
bottom: 0,
left: side === "left" ? -5 : undefined,
position: "absolute",
right: side === "right" ? -5 : undefined,
}}
>
<path d={path} fill={color} />
</svg>
);
};
// eslint-disable-next-line complexity
const ImessageRow = ({
item,
eff,
contact,
accent,
showDelivered,
theme,
}: {
item: ScheduledMessage;
eff: number;
contact?: ImessageContact;
accent: string;
showDelivered: boolean;
theme: FramecnTheme;
}) => {
const outgoing = item.from === "me";
const showTyping =
item.from === "them" &&
item.typingStart !== undefined &&
eff < item.revealAt;
const enterTarget = showTyping ? (item.typingStart ?? 0) : item.revealAt;
const enterProgress = clamp(
(eff - enterTarget) / (16 * (1 / Math.max(0.01, 1))),
0,
1
);
const ease = 1 - (1 - enterProgress) ** 3;
const opacity = ease;
const translateY = 10 * (1 - ease);
const scale = 0.92 + 0.08 * ease;
let reactionScale = 0;
let reactionOpacity = 0;
if (item.reactAt !== undefined && eff >= item.reactAt) {
const reactProgress = clamp((eff - item.reactAt) / 12, 0, 1);
reactionScale =
reactProgress < 0.6
? (reactProgress / 0.6) * 1.15
: 1.15 - ((reactProgress - 0.6) / 0.4) * 0.15;
reactionOpacity = clamp((eff - item.reactAt) / 5, 0, 1);
}
const bg = outgoing ? accent : theme.muted;
const fg = outgoing ? OUTGOING_FG : theme.foreground;
const delivered =
showDelivered && !showTyping && eff > item.revealAt + REVEAL;
const bubble = (
<div
style={{
background: bg,
borderBottomLeftRadius: outgoing ? 19 : 7,
borderBottomRightRadius: outgoing ? 7 : 19,
borderRadius: 19,
color: fg,
fontSize: 16,
lineHeight: 1.32,
maxWidth: "72%",
padding: showTyping ? "11px 14px" : "7px 13px 8px",
position: "relative",
}}
>
{showTyping ? (
<TypingIndicator color={theme.mutedForeground} />
) : (
<span style={{ overflowWrap: "break-word", wordBreak: "break-word" }}>
{item.text}
</span>
)}
{!showTyping && (
<BubbleTail side={outgoing ? "right" : "left"} color={bg} />
)}
{item.reaction !== undefined && (
<div
style={{
alignItems: "center",
background: theme.muted,
borderRadius: "50%",
boxShadow: "0 1px 2px rgba(0,0,0,0.12)",
display: "flex",
fontSize: 15,
height: 30,
justifyContent: "center",
left: outgoing ? -14 : undefined,
opacity: reactionOpacity,
position: "absolute",
right: outgoing ? undefined : -14,
top: -16,
transform: `scale(${reactionScale})`,
transformOrigin: outgoing ? "bottom left" : "bottom right",
width: 30,
}}
>
{item.reaction}
</div>
)}
</div>
);
return (
<div
style={{
alignItems: outgoing ? "flex-end" : "flex-start",
display: "flex",
flexDirection: "column",
gap: 2,
opacity,
transform: `translateY(${translateY}px) scale(${scale})`,
transformOrigin: outgoing ? "bottom right" : "bottom left",
width: "100%",
}}
>
<div
style={{
alignItems: "flex-end",
display: "flex",
gap: 7,
justifyContent: outgoing ? "flex-end" : "flex-start",
width: "100%",
}}
>
{!outgoing && contact !== undefined && (
<Avatar contact={contact} size={28} />
)}
{bubble}
</div>
{delivered && (
<span
style={{
color: theme.mutedForeground,
fontSize: 11,
paddingRight: 4,
}}
>
Delivered
</span>
)}
</div>
);
};
// eslint-disable-next-line complexity
export const ImessageChatFlow = ({
messages = DEFAULT_MESSAGES,
contact,
accentColor,
speed = 1,
fps = 30,
durationInFrames,
theme,
className,
}: ImessageChatFlowProps) => {
const [eff, setEff] = useState(0);
const startRef = useRef<number | null>(null);
const accent = accentColor ?? IMESSAGE_BLUE;
const resolved = useFramecnTheme(theme);
const { items, duration: rawDuration } = imessageChatFlowSchedule(messages);
const totalDuration =
durationInFrames ?? Math.ceil(rawDuration / (speed <= 0 ? 1 : speed));
const durationMs = (totalDuration / fps) * 1000;
useEffect(() => {
let raf: number;
const tick = (now: number) => {
if (startRef.current === null) {
startRef.current = now;
}
const elapsed = now - startRef.current;
const frame = (elapsed / 1000) * fps;
setEff(Math.min(frame * speed, totalDuration));
if (frame * speed < totalDuration) {
raf = requestAnimationFrame(tick);
}
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [fps, speed, totalDuration]);
const activeMe = items.find(
(item) =>
item.from === "me" &&
item.typeStart !== undefined &&
item.sendAt !== undefined &&
eff >= item.typeStart &&
eff < item.sendAt
);
let composerText = "";
let typing = false;
if (
activeMe &&
activeMe.typeStart !== undefined &&
activeMe.sendAt !== undefined
) {
const typeDur = Math.max(
activeMe.sendAt - SEND_GAP - activeMe.typeStart,
1
);
const progress = clamp((eff - activeMe.typeStart) / typeDur, 0, 1);
composerText = revealedText(
activeMe.text,
Math.floor(progress * activeMe.text.length)
);
typing = true;
}
const sendActive = composerText.length > 0;
const sendScale = 1 - 0.16 * sendPulse(items, eff);
const present = items.filter((item) => eff >= item.presenceStart);
let lastMeIndex = -1;
for (const item of items) {
if (item.from === "me") {
lastMeIndex = item.index;
}
}
const deliveredIndex = lastMeIndex === messages.length - 1 ? lastMeIndex : -1;
return (
<Timegroup
className={className}
duration={`${durationMs}ms`}
mode="fixed"
style={{
inset: 0,
position: "absolute",
}}
>
<>
<style>{`
@keyframes framecn-imsg-msg-enter {
from { opacity: 0; transform: translateY(10px) scale(0.92); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes framecn-imsg-send-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(0.84); }
}
@keyframes framecn-imsg-reaction-pop {
0% { opacity: 0; transform: scale(0); }
60% { transform: scale(1.15); }
100% { opacity: 1; transform: scale(1); }
}
`}</style>
<div
style={{
background: "transparent",
display: "flex",
fontFamily:
"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif",
inset: 0,
justifyContent: "center",
position: "absolute",
}}
>
<div
style={{
background: resolved.background,
boxSizing: "border-box",
color: resolved.foreground,
display: "flex",
flexDirection: "column",
height: "100%",
maxWidth: MOBILE_WIDTH,
width: "100%",
}}
>
<div
style={{
alignItems: "center",
backdropFilter: "blur(12px)",
background: resolved.card,
borderBottom: `1px solid ${resolved.border}`,
display: "flex",
gap: 6,
padding: "10px 12px",
}}
>
<FramecnIcon
name="ChevronLeft"
size={28}
color={resolved.foreground}
/>
<div
style={{
alignItems: "center",
display: "flex",
flex: 1,
flexDirection: "column",
gap: 2,
}}
>
{contact !== undefined && (
<Avatar contact={contact} size={30} />
)}
<span
style={{
color: resolved.cardForeground,
fontSize: 12,
fontWeight: 600,
}}
>
{contact?.name ?? "Chat"}
</span>
</div>
<FramecnIcon name="Video" size={24} color={resolved.foreground} />
</div>
<div
style={{
flex: 1,
minHeight: 0,
overflow: "hidden",
position: "relative",
}}
>
<div
style={{
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
gap: 18,
justifyContent: "flex-end",
minHeight: "100%",
padding: "16px 14px 12px",
}}
>
{present.map((item) => (
<ImessageRow
key={item.index}
item={item}
eff={eff}
contact={contact}
accent={accent}
showDelivered={item.index === deliveredIndex}
theme={resolved}
/>
))}
</div>
</div>
<div
style={{
alignItems: "flex-end",
display: "flex",
gap: 8,
padding: "8px 12px 12px",
}}
>
<div
style={{
alignItems: "center",
background: resolved.muted,
borderRadius: "50%",
display: "flex",
height: 34,
justifyContent: "center",
width: 34,
}}
>
<FramecnIcon
name="Plus"
size={22}
color={resolved.mutedForeground}
/>
</div>
<div
style={{
alignItems: "center",
background: resolved.background,
border: `1px solid ${resolved.input}`,
borderRadius: 18,
display: "flex",
flex: 1,
gap: 6,
minHeight: 36,
padding: "0 6px 0 14px",
}}
>
<div
style={{
alignItems: "center",
color: sendActive
? resolved.foreground
: resolved.mutedForeground,
display: "flex",
flex: 1,
fontSize: 16,
}}
>
<span
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{sendActive ? composerText : "iMessage"}
</span>
{typing && (
<Caret
color={accent}
height={18}
radius={1}
blink
marginLeft={composerText.length > 0 ? 2 : 0}
/>
)}
</div>
<div
style={{
alignItems: "center",
background: sendActive ? accent : resolved.muted,
borderRadius: "50%",
display: "flex",
flexShrink: 0,
height: 28,
justifyContent: "center",
transform: `scale(${sendScale})`,
width: 28,
}}
>
<FramecnIcon
name="ArrowUp"
size={20}
color={sendActive ? OUTGOING_FG : resolved.mutedForeground}
/>
</div>
</div>
</div>
</div>
</div>
</>
</Timegroup>
);
};































