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 && ( +