All files / apps/chatbot/src/hooks useComposer.ts

100% Statements 30/30
100% Branches 10/10
100% Functions 7/7
100% Lines 27/27

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74                                    1x 30x 30x   30x 30x   30x 9x 9x     30x 9x       9x           30x   10x 10x   10x 10x 10x   10x   3x 2x 2x     10x   9x       10x 10x 10x           30x    
import { RefObject, useCallback, useRef, useState } from "react";
import { Easing, useSharedValue, withSequence, withTiming } from "react-native-reanimated";
import { Keyboard, TextInput } from "react-native";
 
interface UseComposerArgs {
  inputRef: RefObject<TextInput | null>;
  hasImage: boolean;
  /** Resolves false when the provider rejected the send. */
  send: (text: string) => Promise<boolean>;
}
 
/**
 * Owns the composer's draft text and its send animation.
 *
 * The field clears optimistically so the send feels instant, which means the
 * draft has to be restored when the provider rejects it (no thread yet, a
 * stream already running) — otherwise the user silently loses what they typed.
 */
export const useComposer = ({ inputRef, hasImage, send }: UseComposerArgs) => {
  const [text, setText] = useState("");
  const textRef = useRef("");
 
  const sendArrowY = useSharedValue(0);
  const sendArrowOpacity = useSharedValue(1);
 
  const onChangeText = useCallback((value: string) => {
    textRef.current = value;
    setText(value);
  }, []);
 
  const playSendAnimation = useCallback(() => {
    sendArrowY.value = withSequence(
      withTiming(-20, { duration: 180, easing: Easing.out(Easing.quad) }),
      withTiming(0, { duration: 0 }),
    );
    sendArrowOpacity.value = withSequence(
      withTiming(0, { duration: 160 }),
      withTiming(1, { duration: 220, easing: Easing.out(Easing.ease) }),
    );
  }, [sendArrowY, sendArrowOpacity]);
 
  const onSend = useCallback(
    (value?: string) => {
      const textToSend = (value ?? textRef.current).trim();
      if (textToSend || hasImage) playSendAnimation();
 
      const previousText = textRef.current;
      setText("");
      textRef.current = "";
 
      const restoreDraft = () => {
        // A newer draft typed during the round trip always wins.
        if (textRef.current) return;
        setText(previousText);
        textRef.current = previousText;
      };
 
      send(textToSend)
        .then((accepted) => {
          if (!accepted) restoreDraft();
        })
        .catch(restoreDraft);
 
      setTimeout(() => {
        inputRef.current?.blur();
        Keyboard.dismiss();
      }, 150);
    },
    [send, inputRef, hasImage, playSendAnimation],
  );
 
  return { text, onChangeText, onSend, sendArrowY, sendArrowOpacity };
};