Files
llink/js/mobile/src/features/stream-view/ReactionSheet.tsx
T

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