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',
},
}));
|