380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
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<string>(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 (
|
|
<Modal
|
|
visible={mounted}
|
|
transparent
|
|
animationType="none"
|
|
onRequestClose={dismiss}
|
|
>
|
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
|
<View style={{ flex: 1 }}>
|
|
<Animated.View
|
|
pointerEvents={open ? 'auto' : 'none'}
|
|
style={[
|
|
{ position: 'absolute', inset: 0, backgroundColor: 'black' },
|
|
backdropStyle,
|
|
]}
|
|
>
|
|
<Pressable style={{ flex: 1 }} onPress={dismiss} />
|
|
</Animated.View>
|
|
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
style={{ flex: 1, justifyContent: 'flex-end' }}
|
|
pointerEvents="box-none"
|
|
>
|
|
<GestureDetector gesture={sheetPan}>
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
backgroundColor: '#1c1c1c',
|
|
borderTopLeftRadius: 22,
|
|
borderTopRightRadius: 22,
|
|
overflow: 'hidden',
|
|
},
|
|
sheetStyle,
|
|
]}
|
|
>
|
|
<SafeAreaView edges={['bottom']}>
|
|
<View className="px-5 pt-3 pb-2 items-center">
|
|
{/* Drag handle — affords downward dismissal at a glance. */}
|
|
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
|
|
|
<View className="flex-row items-center justify-between w-full">
|
|
<Text className="text-white text-base font-semibold">
|
|
React
|
|
</Text>
|
|
<Pressable
|
|
onPress={dismiss}
|
|
hitSlop={12}
|
|
accessibilityLabel="Close reactions"
|
|
>
|
|
<X color="rgba(255,255,255,0.6)" size={20} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Existing reactions row — tap a pill to toggle yours. */}
|
|
{activeEmojis.length > 0 || activeTextKeys.length > 0 ? (
|
|
<View className="px-5 pb-3 flex-row flex-wrap gap-2">
|
|
{activeEmojis.map((emoji) => {
|
|
const reactors = reactions![emoji];
|
|
const isMine = reactors.includes(currentHumanId);
|
|
return (
|
|
<Pressable
|
|
key={emoji}
|
|
onPress={() => 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',
|
|
)}
|
|
>
|
|
<Text className="text-base">{emoji}</Text>
|
|
<Text className="text-white/85 text-xs font-semibold">
|
|
{reactors.length}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
|
|
{activeTextKeys.map((key) => {
|
|
const reactors = reactions![key];
|
|
const isMine = reactors.includes(currentHumanId);
|
|
const firstReactor = resolveHumanDisplay(
|
|
reactors[0],
|
|
humans,
|
|
);
|
|
return (
|
|
<Pressable
|
|
key={key}
|
|
onPress={() => 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',
|
|
)}
|
|
>
|
|
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
|
|
<Text className="text-white text-[9px] font-semibold">
|
|
{firstReactor.initials}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
className="text-white/90 text-xs"
|
|
numberOfLines={1}
|
|
>
|
|
{key}
|
|
</Text>
|
|
{reactors.length > 1 ? (
|
|
<Text className="text-white/60 text-xs font-medium">
|
|
{reactors.length}
|
|
</Text>
|
|
) : null}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
) : null}
|
|
|
|
{/* Quick-pick emoji palette — six big tappable buttons. */}
|
|
<View className="px-3 pt-2 pb-4 flex-row justify-around">
|
|
{REACTION_EMOJIS.slice(0, 6).map((emoji) => {
|
|
const isMine =
|
|
reactions?.[emoji]?.includes(currentHumanId) ?? false;
|
|
return (
|
|
<Pressable
|
|
key={emoji}
|
|
onPress={() => 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',
|
|
)}
|
|
>
|
|
<Text style={{ fontSize: 28 }}>{emoji}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Text reaction input — 40-char cap matches desktop. */}
|
|
<View className="px-4 pb-4 flex-row items-center gap-2">
|
|
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
|
|
<TextInput
|
|
value={text}
|
|
onChangeText={(v) =>
|
|
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"
|
|
/>
|
|
</View>
|
|
<Pressable
|
|
onPress={submitText}
|
|
disabled={text.trim().length === 0}
|
|
accessibilityLabel="Send text reaction"
|
|
className={cn(
|
|
'h-11 w-11 items-center justify-center rounded-full',
|
|
text.trim().length === 0 ? 'bg-white/10' : 'bg-white',
|
|
)}
|
|
>
|
|
<Send
|
|
color={
|
|
text.trim().length === 0
|
|
? 'rgba(255,255,255,0.3)'
|
|
: 'black'
|
|
}
|
|
size={18}
|
|
strokeWidth={2}
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
</SafeAreaView>
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
</KeyboardAvoidingView>
|
|
</View>
|
|
</SafeAreaProvider>
|
|
</Modal>
|
|
);
|
|
}
|