Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ai-chat-log): add typewriter animations #4199

Merged
merged 15 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tough-moles-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@twilio-paste/ai-chat-log": minor
"@twilio-paste/core": minor
---

[AI Chat Log] added optional typewriter animation to AIChatMessageBody
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { HTMLPasteProps } from "@twilio-paste/types";
import * as React from "react";

import { AIMessageContext } from "./AIMessageContext";
import { useAnimatedText } from "./utils";

const Sizes: Record<string, BoxStyleProps> = {
default: {
Expand Down Expand Up @@ -35,11 +36,59 @@ export interface AIChatMessageBodyProps extends HTMLPasteProps<"div"> {
* @memberof AIChatMessageBodyProps
*/
size?: "default" | "fullScreen";
/**
* Whether the text should be animated with type writer effect
*
* @default false
* @type {boolean}
* @memberof AIChatMessageBodyProps
*/
animated?: boolean;
/**
* A callback when the animation is started
*
* @default false
* @type {() => void}
* @memberof AIChatMessageBodyProps
*/
onAnimationStart?: () => void;
/**
* A callback when the animation is complete
*
* @default false
* @type {() => void}
* @memberof AIChatMessageBodyProps
*/
onAnimationEnd?: () => void;
}

export const AIChatMessageBody = React.forwardRef<HTMLDivElement, AIChatMessageBodyProps>(
({ children, size = "default", element = "AI_CHAT_MESSAGE_BODY", ...props }, ref) => {
(
{
children,
size = "default",
element = "AI_CHAT_MESSAGE_BODY",
animated = false,
onAnimationEnd,
onAnimationStart,
...props
},
ref,
) => {
const { id } = React.useContext(AIMessageContext);
const [showAnimation] = React.useState(animated && children !== undefined);
const animationSpeed = size === "fullScreen" ? 8 : 10;
const { animatedChildren, isAnimating } = useAnimatedText(children, animationSpeed, showAnimation);

React.useEffect(() => {
if (onAnimationStart && animated && isAnimating) {
onAnimationStart();
}

if (animated && !isAnimating && onAnimationEnd) {
onAnimationEnd();
}
}, [isAnimating, showAnimation]);

return (
<Box
Expand All @@ -55,7 +104,7 @@ export const AIChatMessageBody = React.forwardRef<HTMLDivElement, AIChatMessageB
whiteSpace="pre-wrap"
id={id}
>
{children}
{animatedChildren}
</Box>
);
},
Expand Down
79 changes: 79 additions & 0 deletions packages/paste-core/components/ai-chat-log/src/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React, { useEffect, useState } from "react";

// Hook to animate text content of React elements
export const useAnimatedText = (
children: React.ReactNode,
speed = 10,
enabled = true,
): { animatedChildren: React.ReactNode; isAnimating: boolean } => {
const [animatedChildren, setAnimatedChildren] = useState<React.ReactNode>();
const [textIndex, setTextIndex] = useState(0);

// Effect to increment textIndex at a specified speed
useEffect(() => {
const interval = setInterval(() => {
setTextIndex((prevIndex) => prevIndex + 1);
}, speed);

return () => clearInterval(interval);
}, [speed]);

// Function to calculate the total length of text within nested elements
const calculateTotalTextLength = (nodes: React.ReactNode): number => {
let length = 0;
React.Children.forEach(nodes, (child) => {
if (typeof child === "string") {
length += child.length;
} else if (React.isValidElement(child)) {
length += calculateTotalTextLength(child.props.children);
}
});
return length;
};

// Function to recursively clone children and apply text animation
const cloneChildren = (nodes: React.ReactNode, currentIndex: number): React.ReactNode => {
let currentTextIndex = currentIndex;
return React.Children.map(nodes, (child) => {
if (typeof child === "string") {
// Only include text nodes if their animation has started
if (currentTextIndex > 0) {
const visibleText = child.slice(0, currentTextIndex);
currentTextIndex -= child.length;
return visibleText;
}
return null;
} else if (React.isValidElement(child)) {
const totalChildTextLength = calculateTotalTextLength(child.props.children);
// Only include elements if their text animation has started
if (currentTextIndex > 0) {
const clonedChild = React.cloneElement(child, {}, cloneChildren(child.props.children, currentTextIndex));
currentTextIndex -= totalChildTextLength;
return clonedChild;
} else if (currentTextIndex === 0 && totalChildTextLength === 0) {
return child;
}
return null;
}

return child;
});
};

// Effect to update animated children based on the current text index
useEffect(() => {
if (enabled) {
const totaLength = calculateTotalTextLength(children);
if (textIndex <= totaLength) {
setAnimatedChildren(cloneChildren(children, textIndex));
}
}
}, [children, textIndex, enabled]);

return {
animatedChildren: enabled ? animatedChildren : children,
isAnimating: enabled && textIndex < calculateTotalTextLength(children),
};
};

export default useAnimatedText;
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const BotMessage = (props): JSX.Element => {
) : (
<AIChatMessage variant="bot">
<AIChatMessageAuthor aria-label="Bot said">Good Bot</AIChatMessageAuthor>
<AIChatMessageBody>{props.message as string}</AIChatMessageBody>
<AIChatMessageBody animated>{props.message as string}</AIChatMessageBody>
</AIChatMessage>
);
};
Expand Down
Loading
Loading