Line data Source code
1 : import * as React from 'react';
2 :
3 : import { AgentFace, AgentFaceMorphEvent } from './agent_face.js';
4 : import * as styles from './agent_greeting.module.css';
5 :
6 : /// The questions the agent face asks on an empty session, in the order they rotate.
7 : /// They speak of the user and the agent together, the analysis is not handed off to the agent.
8 1 : export const AGENT_GREETINGS: readonly string[] = [
9 1 : 'What are we exploring?',
10 1 : 'What should we look into?',
11 1 : 'What shall we find out?',
12 1 : 'What should we analyze?',
13 1 : 'Where do we start?'
14 1 : ];
15 :
16 : interface AgentGreetingProps {
17 : /// The props of the face
18 : face?: React.ComponentProps<typeof AgentFace> & { 'data-testid'?: string };
19 : /// The class of the question
20 : questionClassName?: string;
21 : /// The test id of the question
22 : questionTestId?: string;
23 : /// The element of the question
24 : questionElement?: 'h1' | 'p';
25 : /// The questions, the greetings of the agent by default
26 : greetings?: readonly string[];
27 : }
28 :
29 : /// The time to type a character, jittered so the typing feels like a hand
30 1 : const TYPE_MS = 14;
31 1 : const TYPE_JITTER_MS = 10;
32 : /// The time to delete a character
33 1 : const DELETE_MS = 8;
34 :
35 : /// What the question is doing
36 1 : enum Typing {
37 : /// The characters appear one by one
38 1 : Typing,
39 : /// The question is complete
40 1 : Done,
41 : /// The characters disappear one by one before the next question
42 1 : Deleting,
43 1 : }
44 :
45 : /// The agent face with its question right of it, the face and the question are siblings for the layout of the parent.
46 : /// The question is typed character by character. When the face starts to melt into the brand mark, it is deleted
47 : /// and the next question is typed.
48 1 : export function AgentGreeting(props: AgentGreetingProps): React.ReactElement {
49 1 : const greetings = props.greetings ?? AGENT_GREETINGS;
50 1 : const reducedMotion = React.useMemo(() => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false, []);
51 1 : const [index, setIndex] = React.useState(0);
52 1 : const [typing, setTyping] = React.useState(reducedMotion ? Typing.Done : Typing.Typing);
53 1 : const chars = React.useMemo(() => Array.from(greetings[index % greetings.length]), [greetings, index]);
54 1 : const [count, setCount] = React.useState(reducedMotion ? chars.length : 0);
55 :
56 1 : const onMorph = React.useCallback((event: AgentFaceMorphEvent) => {
57 0 : if (event !== AgentFaceMorphEvent.Leaving) return;
58 0 : if (reducedMotion) {
59 0 : setIndex(i => (i + 1) % greetings.length);
60 : } else {
61 0 : setTyping(Typing.Deleting);
62 : }
63 1 : }, [greetings.length, reducedMotion]);
64 :
65 : // Type or delete the next character
66 1 : React.useEffect(() => {
67 1 : if (reducedMotion) {
68 1 : setCount(chars.length);
69 1 : return;
70 1 : }
71 1 : let timer: ReturnType<typeof setTimeout> | undefined;
72 1 : if (typing === Typing.Typing) {
73 1 : if (count < chars.length) {
74 1 : timer = setTimeout(() => setCount(c => c + 1), TYPE_MS + Math.random() * TYPE_JITTER_MS);
75 : } else {
76 1 : setTyping(Typing.Done);
77 : }
78 1 : } else if (typing === Typing.Deleting) {
79 1 : if (count > 0) {
80 1 : timer = setTimeout(() => setCount(c => Math.max(0, c - 1)), DELETE_MS);
81 1 : } else {
82 1 : setIndex(i => (i + 1) % greetings.length);
83 1 : setTyping(Typing.Typing);
84 1 : }
85 1 : }
86 1 : return () => clearTimeout(timer);
87 1 : }, [typing, count, chars, greetings.length, reducedMotion]);
88 :
89 1 : const Question = props.questionElement ?? 'h1';
90 1 : const questionClassName = props.questionClassName == null ? styles.question : `${styles.question} ${props.questionClassName}`;
91 1 : return (
92 1 : <>
93 1 : <AgentFace {...props.face} onMorph={onMorph} />
94 1 : <Question className={questionClassName} data-testid={props.questionTestId} aria-label={chars.join('')}>
95 : {/* Every question takes the cell invisibly, the widest one sizes it so the face does not move */}
96 1 : {greetings.map(text => (
97 1 : <span key={text} className={styles.sizer} aria-hidden>{text}</span>
98 1 : ))}
99 1 : <span className={styles.typed} aria-hidden>
100 1 : {chars.slice(0, count).join('')}
101 1 : <span className={styles.caret} data-active={typing !== Typing.Done ? '' : undefined} />
102 1 : </span>
103 1 : </Question>
104 1 : </>
105 : );
106 1 : }
|