import React, { forwardRef, memo, useEffect, useImperativeHandle, useState, } from 'react'; import { Modal, useWindowDimensions } from 'react-native'; import Animated, { cancelAnimation, interpolate, runOnJS, useAnimatedGestureHandler, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; import { ANIMATION_CONFIG, LONG_PRESS_DURATION, } from '../../core/constants'; import { GestureContext, StoryModalProps, StoryModalPublicMethods } from '../../core/dto/componentsDTO'; import StoryList from '../List'; import ModalStyles from './Modal.styles'; import GestureHandler from './gesture'; const StoryModal = forwardRef( ( { stories, seenStories, duration, videoDuration, storyAvatarSize, textStyle, containerStyle, backgroundColor, videoProps, closeIconColor, modalAnimationDuration = 800, onLoad, onShow, onHide, onSeenStoriesChange, onSwipeUp, ...props }, ref ) => { const [ visible, setVisible ] = useState( false ); const { width: WIDTH, height: HEIGHT} = useWindowDimensions() const x = useSharedValue( 0 ); const y = useSharedValue( HEIGHT ); const animation = useSharedValue( 0 ); const currentStory = useSharedValue( stories[0]?.stories[0]?.id ); const buttonHandled = useSharedValue( false ); const paused = useSharedValue( false ); const durationValue = useSharedValue( duration ); const userIndex = useDerivedValue( () => Math.round( x.value / WIDTH ) ); const storyIndex = useDerivedValue( () => stories[userIndex.value]?.stories.findIndex( ( story ) => story.id === currentStory.value, ) ); const userId = useDerivedValue( () => stories[userIndex.value]?.id ); const previousUserId = useDerivedValue( () => stories[userIndex.value - 1]?.id ); const nextUserId = useDerivedValue( () => stories[userIndex.value + 1]?.id ); const previousStory = useDerivedValue( () => ( storyIndex.value !== undefined ? stories[userIndex.value]?.stories[storyIndex.value - 1]?.id : undefined ) ); const nextStory = useDerivedValue( () => ( storyIndex.value !== undefined ? stories[userIndex.value]?.stories[storyIndex.value + 1]?.id : undefined ) ); const animatedStyles = useAnimatedStyle( () => ( { top: y.value } ) ); const backgroundAnimatedStyles = useAnimatedStyle( () => ( { opacity: interpolate( y.value, [ 0, HEIGHT ], [ 1, 0 ] ), backgroundColor, } ) ); const onClose = () => { 'worklet'; y.value = withTiming( HEIGHT, { duration: modalAnimationDuration }, () => runOnJS( setVisible )( false ), ); }; const stopAnimation = () => { 'worklet'; cancelAnimation( animation ); }; const startAnimation = ( resume = false, newDuration?: number ) => { 'worklet'; if ( newDuration ) { durationValue.value = newDuration; } else { newDuration = durationValue.value; } if ( resume ) { newDuration -= animation.value * newDuration; } else { animation.value = 0; if ( userId.value !== undefined && currentStory.value !== undefined ) { runOnJS( onSeenStoriesChange )( userId.value, currentStory.value ); } } animation.value = withTiming( 1, { duration: newDuration } ); }; const scrollTo = ( id: string, animated = true, sameUser = false ) => { 'worklet'; const newUserIndex = stories.findIndex( ( story ) => story.id === id ); const newX = newUserIndex * WIDTH; x.value = animated ? withTiming( newX, ANIMATION_CONFIG ) : newX; if ( sameUser ) { startAnimation( true ); return; } const newStoryIndex = stories[newUserIndex]?.stories.findIndex( ( story ) => story.id === seenStories.value[id], ); const userStories = stories[newUserIndex]?.stories; currentStory.value = newStoryIndex !== undefined ? userStories?.[newStoryIndex + 1]?.id ?? userStories?.[0]?.id : undefined; }; const toNextStory = ( value = true ) => { 'worklet'; if ( !value ) { return; } if ( !nextStory.value ) { if ( nextUserId.value ) { scrollTo( nextUserId.value ); } else { onClose(); } } else { animation.value = 0; currentStory.value = nextStory.value; } }; const toPreviousStory = () => { 'worklet'; if ( !previousStory.value ) { if ( previousUserId.value ) { scrollTo( previousUserId.value ); } } else { animation.value = 0; currentStory.value = previousStory.value; } }; const show = ( id: string ) => { setVisible( true ); scrollTo( id, false ); }; const onGestureEvent = useAnimatedGestureHandler( { onStart: ( e, ctx: GestureContext ) => { ctx.x = x.value; ctx.pressedX = e.x; ctx.pressedAt = Date.now(); stopAnimation(); paused.value = true; ctx.userId = userId.value; }, onActive: ( e, ctx ) => { if ( ctx.x === x.value && ( ctx.vertical || ( Math.abs( e.velocityX ) < Math.abs( e.velocityY ) ) ) ) { ctx.vertical = true; y.value = e.translationY / 2; } else { ctx.moving = true; x.value = Math.max( 0, Math.min( ctx.x + -e.translationX, WIDTH * ( stories.length - 1 ) ), ); } }, onFinish: ( e, ctx ) => { if ( ctx.vertical ) { if ( e.translationY > 100 ) { onClose(); } else { if ( e.translationY < -100 && onSwipeUp ) { runOnJS( onSwipeUp )( stories[userIndex.value]?.id, stories[userIndex.value]?.stories[storyIndex.value ?? 0]?.id, ); } y.value = withTiming( 0 ); startAnimation( true ); } } else if ( ctx.moving ) { const diff = x.value - ctx.x; let newX; if ( Math.abs( diff ) < WIDTH / 4 ) { newX = ctx.x; } else { newX = diff > 0 ? Math.ceil( x.value / WIDTH ) * WIDTH : Math.floor( x.value / WIDTH ) * WIDTH; } const newUserId = stories[Math.round( newX / WIDTH )]?.id; if ( newUserId !== undefined ) { scrollTo( newUserId, true, newUserId === ctx.userId ); } } else if ( ctx.pressedAt + LONG_PRESS_DURATION < Date.now() ) { startAnimation( true ); } else if ( ctx.pressedX < WIDTH / 2 ) { toPreviousStory(); } else if ( !buttonHandled.value ) { toNextStory(); } ctx.moving = false; ctx.vertical = false; buttonHandled.value = false; paused.value = false; ctx.userId = undefined; }, } ); useImperativeHandle( ref, () => ( { show, hide: onClose } ) ); useEffect( () => { if ( visible ) { if ( currentStory.value !== undefined ) { onShow?.( currentStory.value ); } onLoad?.(); y.value = withTiming( 0, { duration: modalAnimationDuration } ); } else if ( currentStory.value !== undefined ) { onHide?.( currentStory.value ); } }, [ visible ] ); useAnimatedReaction( () => animation.value, ( res, prev ) => res !== prev && toNextStory( res === 1 ), [ animation.value ], ); return ( {stories?.map( ( story, index ) => ( { onLoad?.(); startAnimation( undefined, value !== undefined ? ( videoDuration ?? value ) : duration, ); }} avatarSize={storyAvatarSize} textStyle={textStyle} buttonHandled={buttonHandled} paused={paused} videoProps={videoProps} closeColor={closeIconColor} key={story.id} {...props} /> ) )} ); } ); export default memo( StoryModal );