All files / apps/host/src/components/ChatbotFab index.tsx

100% Statements 37/37
92.59% Branches 25/27
100% Functions 8/8
100% Lines 33/33

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 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133                                            2x 19x 19x 19x 19x   19x 19x   19x 8x                     19x 11x 10x   1x     19x 11x 3x     19x 19x 19x   19x 13x 13x   13x   1x 1x         1x     12x   1x         1x       11x 10x                 19x                                                         19x                
import React, { useEffect, useMemo, useRef } from 'react';
import { useSafeAreaFrame, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Animated, Platform, Pressable } from 'react-native';
 
import { ChatbotIcon } from '@repo/ui/icons/Chatbot';
import { makeStyles } from '@repo/ui/themes/makeStyles';
import { useTheme } from '@repo/ui/themes/ThemeContext';
 
import { useFabStateSelector } from '@/contexts/FabContext';
 
type ChatbotFabProps = {
  onPress: () => void;
  /**
   * Bottom edge of the navigation container in window coordinates. Remotes
   * measure their anchor button against the safe-area frame bottom, but on
   * Android that frame extends past where the container actually ends, so the
   * reported offset runs too high. The container measurement corrects by the
   * exact delta.
   */
  containerBottom: number;
};
 
export const ChatbotFab = ({ onPress, containerBottom }: ChatbotFabProps) => {
  const { theme } = useTheme();
  const styles = useStyles();
  const insets = useSafeAreaInsets();
  const frame = useSafeAreaFrame();
 
  const fabOffset = useFabStateSelector(s => s.fabOffset);
  const fabHidden = useFabStateSelector(s => s.fabHidden);
 
  const restingBottom = useMemo(() => {
    return (
      theme.metrics.spacing[3] +
      theme.metrics.spacing[4] +
      (Platform.OS === 'ios' ? 0 : insets.bottom)
    );
  }, [theme.metrics.spacing, insets.bottom]);
 
  // On Android, shift the reported offset by how far the safe-area frame bottom
  // sits below the real container bottom, so the FAB lands `gap` above the
  // button. iOS shares one frame and needs no correction. The rest position (0)
  // and the off-screen hide sentinel (negative) are passed through untouched.
  const effectiveFabOffset = useMemo(() => {
    if (fabOffset <= 0 || Platform.OS !== 'android' || !containerBottom) {
      return fabOffset;
    }
    return fabOffset - (frame.y + frame.height - containerBottom);
  }, [fabOffset, containerBottom, frame.y, frame.height]);
 
  const targetTranslateY = useMemo(() => {
    if (effectiveFabOffset === 0) return 0;
    return -(effectiveFabOffset - restingBottom);
  }, [effectiveFabOffset, restingBottom]);
 
  const animatedTranslateY = useRef(new Animated.Value(0)).current;
  const animatedOpacity = useRef(new Animated.Value(fabHidden ? 0 : 1)).current;
  const prevFabHiddenRef = useRef(fabHidden);
 
  useEffect(() => {
    const wasHidden = prevFabHiddenRef.current;
    prevFabHiddenRef.current = fabHidden;
 
    if (wasHidden && !fabHidden) {
      // Becoming visible: snap position instantly, then fade in.
      animatedTranslateY.setValue(targetTranslateY);
      Animated.timing(animatedOpacity, {
        toValue: 1,
        duration: 200,
        useNativeDriver: true,
      }).start();
      return;
    }
 
    if (!wasHidden && fabHidden) {
      // Becoming hidden: fade out.
      Animated.timing(animatedOpacity, {
        toValue: 0,
        duration: 150,
        useNativeDriver: true,
      }).start();
      return;
    }
 
    // Position changes while visible: smooth slide up/down.
    if (!fabHidden) {
      Animated.spring(animatedTranslateY, {
        toValue: targetTranslateY,
        useNativeDriver: true,
        tension: 40,
        friction: 8,
      }).start();
    }
  }, [targetTranslateY, fabHidden, animatedTranslateY, animatedOpacity]);
 
  return (
    <Animated.View
      style={[
        styles.container,
        {
          bottom: restingBottom,
          opacity: animatedOpacity,
          transform: [{ translateY: animatedTranslateY }],
        },
      ]}
      pointerEvents={fabHidden ? 'none' : 'auto'}
    >
      <Pressable
        onPress={onPress}
        testID="chatbot-fab"
        accessibilityRole="button"
        accessibilityLabel="Open Flash Bot chat"
      >
        <ChatbotIcon
          height={52}
          width={52}
          color={theme.isNewTheme ? theme.colors.icon.active : undefined}
          detailColor={theme.isNewTheme ? theme.colors.text.error : undefined}
        />
      </Pressable>
    </Animated.View>
  );
};
 
const useStyles = makeStyles(() => ({
  container: {
    position: 'absolute',
    right: 16,
    justifyContent: 'center',
    alignItems: 'center',
  },
}));