import { useEffect, useMemo, useState } from 'react'; import { Dimensions, KeyboardAvoidingView, Modal, Platform, Pressable, Text, TextInput, View, } from 'react-native'; import { initialWindowMetrics, SafeAreaProvider, SafeAreaView, } from 'react-native-safe-area-context'; import { Send, X } from 'lucide-react-native'; import * as Haptics from 'expo-haptics'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, Extrapolation, interpolate, runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from 'react-native-reanimated'; import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types'; import { resolveHumanDisplay } from '@/lib/humans'; import { sanitizeReactionText } from '@/lib/firestore-particles'; import { cn } from '@/lib/utils'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; const TEXT_REACTION_MAX = 40; const SCREEN_HEIGHT = Dimensions.get('window').height; const ANIMATION_MS = 240; const EMOJI_SET = new Set(REACTION_EMOJIS); interface ReactionSheetProps { open: boolean; onClose: () => void; reactions: Reactions; currentHumanId: string; humans: Human[] | undefined; /** * Toggle a reaction (emoji or text). Adds if the current human hasn't * reacted, removes if they have. Mirrors desktop's `onToggle` exactly. */ onToggle: (key: string) => void; } /** * Slide-up reaction sheet — the mobile replacement for desktop's right-edge * reaction stack. Tap an emoji to toggle, or send a custom text reaction * (40-char cap). Existing reactions appear as toggleable pills at the top. * * Playback is suspended via `useSuspendPlayback` while the sheet is open so * the active particle doesn't auto-advance under the user. Drag the sheet * down past 30% of its travel to dismiss; everything else springs back. */ export function ReactionSheet({ open, onClose, reactions, currentHumanId, humans, onToggle, }: ReactionSheetProps) { // Suspend playback whenever the sheet is mounted-and-open. The Modal // controls visibility so we tie the suspender to `open` directly. useSuspendPlayback(open, 'reactions-sheet'); // We mount the modal slightly delayed from `open` so the slide-up animation // has its starting position rendered. Using local `mounted` state lets us // play the close animation before unmounting. const [mounted, setMounted] = useState(false); const translateY = useSharedValue(SCREEN_HEIGHT); useEffect(() => { if (open) { // Schedule the slide-in after the modal mounts (handled at render time). requestAnimationFrame(() => { translateY.value = withSpring(0, { damping: 24, stiffness: 260, mass: 0.7, }); }); } else if (mounted) { translateY.value = withTiming( SCREEN_HEIGHT, { duration: ANIMATION_MS, easing: Easing.in(Easing.cubic) }, (finished) => { if (finished) runOnJS(setMounted)(false); }, ); } // intentional: only react to `open`. Closing animation reads from `mounted`. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const dismiss = () => { onClose(); }; const sheetPan = Gesture.Pan() .activeOffsetY(10) .failOffsetX([-25, 25]) .onUpdate((e) => { 'worklet'; // Reanimated shared values are mutated by design; react-hooks/immutability // doesn't model worklets, so the mutations below are flagged spuriously. // eslint-disable-next-line react-hooks/immutability translateY.value = Math.max(0, e.translationY); }) .onEnd((e) => { 'worklet'; if (e.translationY > 120 || e.velocityY > 800) { runOnJS(dismiss)(); } else { // eslint-disable-next-line react-hooks/immutability translateY.value = withSpring(0, { damping: 24, stiffness: 260, mass: 0.7, }); } }); const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); const backdropStyle = useAnimatedStyle(() => { const opacity = interpolate( translateY.value, [0, SCREEN_HEIGHT * 0.7], [0.55, 0], Extrapolation.CLAMP, ); return { opacity }; }); // --- Existing reaction pills --- const activeEmojis = REACTION_EMOJIS.filter( (e) => reactions?.[e] && (reactions[e]?.length ?? 0) > 0, ); const activeTextKeys = useMemo( () => Object.keys(reactions ?? {}).filter( (k) => !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0, ), [reactions], ); // --- Text reaction input --- const [text, setText] = useState(''); // Mount on open (staying mounted through the exit animation) and clear the // input. Render-time adjustment avoids a setState-in-effect cascade. const [prevOpen, setPrevOpen] = useState(open); if (open !== prevOpen) { setPrevOpen(open); if (open) { setMounted(true); setText(''); } } const submitText = () => { const trimmed = text.trim(); if (!trimmed) return; void Haptics.selectionAsync(); onToggle(trimmed.slice(0, TEXT_REACTION_MAX)); setText(''); onClose(); }; const handleEmoji = (emoji: string) => { void Haptics.selectionAsync(); onToggle(emoji); onClose(); }; if (!mounted) return null; return ( {/* Drag handle — affords downward dismissal at a glance. */} React {/* Existing reactions row — tap a pill to toggle yours. */} {activeEmojis.length > 0 || activeTextKeys.length > 0 ? ( {activeEmojis.map((emoji) => { const reactors = reactions![emoji]; const isMine = reactors.includes(currentHumanId); return ( handleEmoji(emoji)} className={cn( 'flex-row items-center gap-1.5 rounded-full px-3 py-1.5', isMine ? 'bg-white/25 border border-white/40' : 'bg-white/10', )} > {emoji} {reactors.length} ); })} {activeTextKeys.map((key) => { const reactors = reactions![key]; const isMine = reactors.includes(currentHumanId); const firstReactor = resolveHumanDisplay( reactors[0], humans, ); return ( handleEmoji(key)} className={cn( 'flex-row items-center gap-1.5 rounded-full px-2.5 py-1.5 max-w-[220px]', isMine ? 'bg-white/25 border border-white/40' : 'bg-white/10', )} > {firstReactor.initials} {key} {reactors.length > 1 ? ( {reactors.length} ) : null} ); })} ) : null} {/* Quick-pick emoji palette — six big tappable buttons. */} {REACTION_EMOJIS.slice(0, 6).map((emoji) => { const isMine = reactions?.[emoji]?.includes(currentHumanId) ?? false; return ( handleEmoji(emoji)} accessibilityLabel={`React with ${emoji}`} className={cn( 'h-14 w-14 items-center justify-center rounded-full', isMine ? 'bg-white/25' : 'bg-white/10', )} > {emoji} ); })} {/* Text reaction input — 40-char cap matches desktop. */} setText( sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX), ) } placeholder="Send a quick reply..." placeholderTextColor="rgba(255,255,255,0.4)" maxLength={TEXT_REACTION_MAX} autoCapitalize="none" autoCorrect={false} onSubmitEditing={submitText} returnKeyType="send" className="text-white text-base" /> ); }