diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx index b432c59bf9..b356485558 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx @@ -57,6 +57,7 @@ const renderComponent = (onRequestClose = jest.fn()) => { describe('HotAndColdModal', () => { const toggleUpvote = jest.fn(); const toggleDownvote = jest.fn(); + const cancelHotTakeVote = jest.fn(); const dismissCurrent = jest.fn(); const logEvent = jest.fn(); @@ -65,6 +66,7 @@ describe('HotAndColdModal', () => { mockedUseVoteHotTake.mockReturnValue({ toggleUpvote, toggleDownvote, + cancelHotTakeVote, }); mockedUseLogContext.mockReturnValue({ logEvent, @@ -176,6 +178,40 @@ describe('HotAndColdModal', () => { expect(dismissCurrent).toHaveBeenCalledTimes(1); }); + it('should trigger neutral vote flow for skip action', () => { + jest.useFakeTimers(); + const currentTake = createHotTake('skip-take'); + + mockedUseDiscoverHotTakes.mockReturnValue({ + hotTakes: [currentTake], + currentTake, + nextTake: null, + isEmpty: false, + isLoading: false, + dismissCurrent, + }); + + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Skip hot take' })); + + expect(cancelHotTakeVote).toHaveBeenCalledWith({ id: currentTake.id }); + expect(toggleUpvote).not.toHaveBeenCalled(); + expect(toggleDownvote).not.toHaveBeenCalled(); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SkipHotTake, + target_id: currentTake.id, + }), + ); + + act(() => { + jest.runAllTimers(); + }); + + expect(dismissCurrent).toHaveBeenCalledTimes(1); + }); + it('should render empty state share button and close modal on click', () => { mockedUseDiscoverHotTakes.mockReturnValue({ hotTakes: [], diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 92bba6b775..928e30fbe5 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -30,8 +30,25 @@ const BUTTON_DISMISS_ANIMATION_MS = 620; const DISMISS_FLY_DISTANCE = 760; const BUTTON_DISMISS_FLY_DISTANCE = 620; const BUTTON_FLY_KICK_DELAY_MS = 42; +const SKIP_DISMISS_ANIMATION_MS = 520; +const SKIP_DISMISS_FLY_DISTANCE = 600; +const SKIP_DRAG_ELASTICITY_FACTOR = 0.3; +const COLD_ACCENT_COLOR = '#123a88'; const HOT_TAKE_CARD_HEIGHT = '28rem'; +const getElasticDelta = (delta: number): number => { + const absoluteDelta = Math.abs(delta); + if (absoluteDelta <= SWIPE_THRESHOLD) { + return delta; + } + + const overshoot = absoluteDelta - SWIPE_THRESHOLD; + return ( + Math.sign(delta) * + (SWIPE_THRESHOLD + overshoot * SKIP_DRAG_ELASTICITY_FACTOR) + ); +}; + const EFFECT_KEYFRAMES = ` @keyframes hotTakeFlame { 0% { transform: translateX(-50%) translateY(1px) scaleY(0.92) scaleX(1.08); opacity: 0.72; } @@ -63,6 +80,20 @@ const EFFECT_KEYFRAMES = ` 0%, 100% { opacity: 0.5; transform: translateY(0); } 50% { opacity: 0.9; transform: translateY(2px); } } + @keyframes hotTakeSleepFloat { + 0% { transform: translateX(-50%) translateY(0) scale(0.78); opacity: 0; } + 18% { opacity: 1; } + 100% { transform: translateX(-50%) translateY(-72px) scale(1.06); opacity: 0; } + } + @keyframes hotTakeBubbleRise { + 0% { transform: translateY(0) scale(0.72); opacity: 0; } + 18% { opacity: 0.95; } + 100% { transform: translateY(-78px) scale(1.14); opacity: 0; } + } + @keyframes hotTakeSleepBreath { + 0%, 100% { opacity: 0.56; transform: translateY(0); } + 50% { opacity: 0.96; transform: translateY(-2px); } + } @keyframes hotTakeBadgePulse { 0% { transform: translateX(-50%) scale(0.92); opacity: 0; } 100% { transform: translateX(-50%) scale(1); opacity: 1; } @@ -167,11 +198,71 @@ const SNOWFLAKES: ReadonlyArray<{ { left: '52%', top: '35%', size: 5, delay: 0.65, duration: 3.1 }, ]; +const SLEEP_ZS: ReadonlyArray<{ + left: string; + bottom: string; + size: number; + delay: number; + duration: number; + rotate: number; +}> = [ + { left: '20%', bottom: '14%', size: 19, delay: 0, duration: 2.2, rotate: -6 }, + { + left: '32%', + bottom: '22%', + size: 16, + delay: 0.35, + duration: 2.6, + rotate: 4, + }, + { + left: '47%', + bottom: '16%', + size: 22, + delay: 0.12, + duration: 2.4, + rotate: -3, + }, + { + left: '60%', + bottom: '24%', + size: 18, + delay: 0.55, + duration: 2.7, + rotate: 6, + }, + { + left: '74%', + bottom: '18%', + size: 20, + delay: 0.22, + duration: 2.5, + rotate: -4, + }, +]; + +const SLEEP_BUBBLES: ReadonlyArray<{ + left: string; + bottom: string; + size: number; + delay: number; + duration: number; +}> = [ + { left: '12%', bottom: '10%', size: 8, delay: 0, duration: 2.2 }, + { left: '24%', bottom: '15%', size: 6, delay: 0.5, duration: 2.6 }, + { left: '38%', bottom: '8%', size: 10, delay: 0.2, duration: 2.3 }, + { left: '50%', bottom: '13%', size: 7, delay: 0.7, duration: 2.7 }, + { left: '62%', bottom: '9%', size: 9, delay: 0.35, duration: 2.4 }, + { left: '76%', bottom: '14%', size: 6, delay: 0.95, duration: 2.8 }, + { left: '88%', bottom: '8%', size: 8, delay: 0.45, duration: 2.5 }, +]; + const HotTakeCard = ({ hotTake, isTop, offset, swipeDelta, + skipDeltaY = 0, isDismissAnimating, isDragging, dismissDurationMs, @@ -180,26 +271,48 @@ const HotTakeCard = ({ isTop: boolean; offset: number; swipeDelta: number; + skipDeltaY?: number; isDismissAnimating: boolean; isDragging: boolean; dismissDurationMs: number; }): ReactElement => { + const isSkipAnimating = isTop && isDismissAnimating && skipDeltaY !== 0; + const isSkipDragging = isTop && !isDismissAnimating && skipDeltaY < 0; const rotation = isTop ? Math.max(Math.min(swipeDelta * 0.08, 18), -18) : 0; const translateX = isTop ? swipeDelta : 0; const stackScale = isTop ? 1 : 1 - offset * 0.05; const translateY = isTop ? 0 : offset * 8; - const dismissProgress = - isTop && isDismissAnimating - ? Math.min(Math.abs(swipeDelta) / DISMISS_FLY_DISTANCE, 1) - : 0; + const getDismissProgress = (): number => { + if (!isTop || !isDismissAnimating) { + return 0; + } + if (isSkipAnimating) { + return Math.min(Math.abs(skipDeltaY) / SKIP_DISMISS_FLY_DISTANCE, 1); + } + return Math.min(Math.abs(swipeDelta) / DISMISS_FLY_DISTANCE, 1); + }; + const dismissProgress = getDismissProgress(); const scale = isTop ? 1 - dismissProgress * 0.06 : stackScale; const dismissLift = isTop ? dismissProgress * -22 : 0; - const translateYWithOutro = translateY + dismissLift; + const translateYWithOutro = + translateY + dismissLift + (isTop ? skipDeltaY : 0); const intensity = isTop ? Math.min(Math.abs(swipeDelta) / SWIPE_THRESHOLD, 1) : 0; const effectIntensity = isTop ? intensity ** 0.78 : 0; + const skipDragIntensity = isTop + ? Math.min(Math.abs(skipDeltaY) / SWIPE_THRESHOLD, 1) + : 0; + let skipEffectIntensity = 0; + if (isTop) { + if (isSkipAnimating) { + skipEffectIntensity = dismissProgress; + } else if (isSkipDragging) { + skipEffectIntensity = skipDragIntensity ** 0.78; + } + } + const isSkipVisualActive = isTop && skipEffectIntensity > 0.02; const getSwipeDirection = (): 'right' | 'left' | null => { if (!isTop || Math.abs(swipeDelta) <= 20) { return null; @@ -211,7 +324,41 @@ const HotTakeCard = ({ const accentColor = swipeDirection === 'right' ? 'var(--theme-accent-ketchup-default)' - : 'var(--theme-accent-blueCheese-default)'; + : COLD_ACCENT_COLOR; + const skipAccentColor = 'var(--theme-accent-blueCheese-default)'; + let activeBorderColor; + if (swipeDirection) { + activeBorderColor = `color-mix(in srgb, ${accentColor} ${Math.round( + effectIntensity * 100, + )}%, var(--theme-border-subtlest-tertiary))`; + } else if (isSkipVisualActive) { + activeBorderColor = `color-mix(in srgb, ${skipAccentColor} ${Math.round( + skipEffectIntensity * 100, + )}%, var(--theme-border-subtlest-tertiary))`; + } + + let activeBoxShadow; + if (swipeDirection) { + activeBoxShadow = `0 0 ${6 + effectIntensity * 24}px ${ + 2 + effectIntensity * 8 + }px color-mix(in srgb, ${accentColor} ${Math.round( + effectIntensity * 65, + )}%, transparent), 0 0 ${10 + effectIntensity * 34}px ${ + 4 + effectIntensity * 14 + }px color-mix(in srgb, ${accentColor} ${Math.round( + effectIntensity * 42, + )}%, transparent)`; + } else if (isSkipVisualActive) { + activeBoxShadow = `0 0 ${6 + skipEffectIntensity * 22}px ${ + 2 + skipEffectIntensity * 8 + }px color-mix(in srgb, ${skipAccentColor} ${Math.round( + skipEffectIntensity * 60, + )}%, transparent), 0 0 ${10 + skipEffectIntensity * 28}px ${ + 4 + skipEffectIntensity * 12 + }px color-mix(in srgb, ${skipAccentColor} ${Math.round( + skipEffectIntensity * 42, + )}%, transparent)`; + } let transition = 'transform 0.3s ease, border-color 0.2s ease, box-shadow 0.2s ease'; if (isTop) { @@ -240,22 +387,8 @@ const HotTakeCard = ({ isTop && isDismissAnimating ? `blur(${dismissProgress * 1.8}px)` : undefined, - borderColor: swipeDirection - ? `color-mix(in srgb, ${accentColor} ${Math.round( - effectIntensity * 100, - )}%, var(--theme-border-subtlest-tertiary))` - : undefined, - boxShadow: swipeDirection - ? `0 0 ${6 + effectIntensity * 24}px ${ - 2 + effectIntensity * 8 - }px color-mix(in srgb, ${accentColor} ${Math.round( - effectIntensity * 65, - )}%, transparent), 0 0 ${10 + effectIntensity * 34}px ${ - 4 + effectIntensity * 14 - }px color-mix(in srgb, ${accentColor} ${Math.round( - effectIntensity * 42, - )}%, transparent)` - : undefined, + borderColor: activeBorderColor, + boxShadow: activeBoxShadow, }} > {/* eslint-disable-next-line react/no-unknown-property */} @@ -449,17 +582,114 @@ const HotTakeCard = ({ )} + {isSkipVisualActive && ( +
+
+
+ {SLEEP_ZS.map((sleepZ) => ( + + Z + + ))} + {SLEEP_BUBBLES.map((bubble) => ( +
+ ))} +
+ )} + {isTop && swipeDirection && (
)} + {isSkipVisualActive && ( +
+ SKIP 😴 +
+ )} +
{hotTake.emoji} @@ -595,7 +840,7 @@ const HotAndColdModal = ({ }: ModalProps): ReactElement => { const { currentTake, nextTake, isEmpty, isLoading, dismissCurrent } = useDiscoverHotTakes(); - const { toggleUpvote, toggleDownvote } = useVoteHotTake(); + const { toggleUpvote, toggleDownvote, cancelHotTakeVote } = useVoteHotTake(); const { logEvent } = useLogContext(); const { user } = useAuthContext(); const [swipeDelta, setSwipeDelta] = useState(0); @@ -608,6 +853,8 @@ const HotAndColdModal = ({ const animatingTakeIdRef = useRef(null); const flyTimerRef = useRef | null>(null); const dismissTimerRef = useRef | null>(null); + const [skipDelta, setSkipDelta] = useState(0); + const swipeDeltaYRef = useRef(0); useEffect(() => { animatingTakeIdRef.current = animatingTakeId; @@ -617,6 +864,8 @@ const HotAndColdModal = ({ if (!isAnimating) { setSwipeDelta(0); swipeDeltaRef.current = 0; + setSkipDelta(0); + swipeDeltaYRef.current = 0; setIsDragging(false); } }, [currentTake?.id, isAnimating]); @@ -632,12 +881,18 @@ const HotAndColdModal = ({ }; }, []); - const handleDismiss = useCallback( - (direction: 'left' | 'right', source: 'swipe' | 'button' = 'swipe') => { - if (!currentTake || isAnimating) { - return; - } - + const startDismissAnimation = useCallback( + ({ + takeId, + durationMs, + flyDelayMs, + onFly, + }: { + takeId: string; + durationMs: number; + flyDelayMs: number; + onFly: () => void; + }) => { if (flyTimerRef.current) { clearTimeout(flyTimerRef.current); } @@ -645,18 +900,50 @@ const HotAndColdModal = ({ clearTimeout(dismissTimerRef.current); } - const isButtonDismiss = source === 'button'; - const currentDismissDurationMs = - source === 'button' - ? BUTTON_DISMISS_ANIMATION_MS - : DISMISS_ANIMATION_MS; - const flyKickDelayMs = isButtonDismiss ? BUTTON_FLY_KICK_DELAY_MS : 0; - - animatingTakeIdRef.current = currentTake.id; - setAnimatingTakeId(currentTake.id); - setDismissDurationMs(currentDismissDurationMs); + animatingTakeIdRef.current = takeId; + setAnimatingTakeId(takeId); + setDismissDurationMs(durationMs); setIsAnimating(true); setIsDragging(false); + + flyTimerRef.current = setTimeout(() => { + if (animatingTakeIdRef.current !== takeId) { + flyTimerRef.current = null; + return; + } + onFly(); + flyTimerRef.current = null; + }, flyDelayMs); + + dismissTimerRef.current = setTimeout(() => { + if (animatingTakeIdRef.current !== takeId) { + dismissTimerRef.current = null; + return; + } + setSwipeDelta(0); + swipeDeltaRef.current = 0; + setSkipDelta(0); + swipeDeltaYRef.current = 0; + animatingTakeIdRef.current = null; + setAnimatingTakeId(null); + dismissCurrent(); + setIsAnimating(false); + dismissTimerRef.current = null; + }, durationMs); + }, + [dismissCurrent], + ); + + const handleDismiss = useCallback( + (direction: 'left' | 'right', source: 'swipe' | 'button' = 'swipe') => { + if (!currentTake || isAnimating) { + return; + } + + const isButtonSource = source === 'button'; + const durationMs = isButtonSource + ? BUTTON_DISMISS_ANIMATION_MS + : DISMISS_ANIMATION_MS; const vote = direction === 'right' ? 'hot' : 'cold'; logEvent({ @@ -677,10 +964,9 @@ const HotAndColdModal = ({ }); } - // Start from current drag position, then finish the swipe with momentum. let initialPush: number; let flyDistance: number; - if (source === 'button') { + if (isButtonSource) { initialPush = direction === 'right' ? SWIPE_THRESHOLD * 0.45 @@ -698,34 +984,18 @@ const HotAndColdModal = ({ direction === 'right' ? DISMISS_FLY_DISTANCE : -DISMISS_FLY_DISTANCE; } setSwipeDelta(initialPush); - const dismissingTakeId = currentTake.id; - flyTimerRef.current = setTimeout(() => { - if (animatingTakeIdRef.current !== dismissingTakeId) { - flyTimerRef.current = null; - return; - } - setSwipeDelta(flyDistance); - flyTimerRef.current = null; - }, flyKickDelayMs); - dismissTimerRef.current = setTimeout(() => { - if (animatingTakeIdRef.current !== dismissingTakeId) { - dismissTimerRef.current = null; - return; - } - setSwipeDelta(0); - swipeDeltaRef.current = 0; - animatingTakeIdRef.current = null; - setAnimatingTakeId(null); - dismissCurrent(); - setIsAnimating(false); - dismissTimerRef.current = null; - }, currentDismissDurationMs); + startDismissAnimation({ + takeId: currentTake.id, + durationMs, + flyDelayMs: isButtonSource ? BUTTON_FLY_KICK_DELAY_MS : 0, + onFly: () => setSwipeDelta(flyDistance), + }); }, [ currentTake, isAnimating, - dismissCurrent, + startDismissAnimation, toggleDownvote, toggleUpvote, logEvent, @@ -733,18 +1003,50 @@ const HotAndColdModal = ({ ], ); + const handleSkip = useCallback( + (source: 'swipe' | 'button' = 'button') => { + if (!currentTake || isAnimating) { + return; + } + + logEvent({ + event_name: LogEvent.SkipHotTake, + target_id: currentTake.id, + }); + + cancelHotTakeVote({ id: currentTake.id }); + + startDismissAnimation({ + takeId: currentTake.id, + durationMs: SKIP_DISMISS_ANIMATION_MS, + flyDelayMs: source === 'button' ? BUTTON_FLY_KICK_DELAY_MS : 0, + onFly: () => setSkipDelta(-SKIP_DISMISS_FLY_DISTANCE), + }); + }, + [ + cancelHotTakeVote, + currentTake, + isAnimating, + startDismissAnimation, + logEvent, + ], + ); + const isCurrentTakeAnimating = !!currentTake && isAnimating && animatingTakeId === currentTake.id; const cardSwipeDelta = isAnimating && !isCurrentTakeAnimating ? 0 : swipeDelta; + const cardSkipDelta = isAnimating && !isCurrentTakeAnimating ? 0 : skipDelta; const handleSwiped = (direction: 'left' | 'right') => { setIsDragging(false); + setSkipDelta(0); if (Math.abs(swipeDeltaRef.current) > SWIPE_THRESHOLD) { handleDismiss(direction, 'swipe'); } else { setSwipeDelta(0); swipeDeltaRef.current = 0; + swipeDeltaYRef.current = 0; } }; @@ -753,11 +1055,33 @@ const HotAndColdModal = ({ if (!isAnimating) { setIsDragging(true); setSwipeDelta(e.deltaX); + if (e.deltaY < 0 && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + setSkipDelta(getElasticDelta(e.deltaY)); + } else { + setSkipDelta(0); + } swipeDeltaRef.current = e.deltaX; + swipeDeltaYRef.current = e.deltaY; } }, onSwipedLeft: () => handleSwiped('left'), onSwipedRight: () => handleSwiped('right'), + onSwipedUp: () => { + setIsDragging(false); + if ( + swipeDeltaYRef.current < 0 && + Math.abs(swipeDeltaYRef.current) > SWIPE_THRESHOLD + ) { + setSwipeDelta(0); + swipeDeltaRef.current = 0; + handleSkip('swipe'); + } else { + setSwipeDelta(0); + swipeDeltaRef.current = 0; + setSkipDelta(0); + swipeDeltaYRef.current = 0; + } + }, trackMouse: true, preventScrollOnSwipe: true, touchEventOptions: { passive: false }, @@ -807,13 +1131,14 @@ const HotAndColdModal = ({ isTop offset={0} swipeDelta={cardSwipeDelta} + skipDeltaY={cardSkipDelta} isDismissAnimating={isCurrentTakeAnimating} isDragging={isDragging} dismissDurationMs={dismissDurationMs} />
-
+