diff --git a/src/App.tsx b/src/App.tsx index 03481ce01..ae11833c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,6 +65,7 @@ import {Provider as PortalProvider} from '#/components/Portal' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import * as Toast from '#/components/Toast' import {ToastOutlet} from '#/components/Toast' +import {SixSevenOutlet} from '#/features/sixSeven' import { prefetchAgeAssuranceConfig, Provider as AgeAssuranceV2Provider, @@ -179,6 +180,7 @@ function InnerApp() { + diff --git a/src/App.web.tsx b/src/App.web.tsx index 16126eada..a0d1d10c2 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -70,6 +70,7 @@ import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbe import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import * as Toast from '#/components/Toast' import {ToastOutlet} from '#/components/Toast' +import {SixSevenOutlet} from '#/features/sixSeven' import { prefetchAgeAssuranceConfig, Provider as AgeAssuranceV2Provider, @@ -274,6 +275,7 @@ function InnerApp() { + diff --git a/src/features/sixSeven/ConfettiBurst.tsx b/src/features/sixSeven/ConfettiBurst.tsx new file mode 100644 index 000000000..d06273c65 --- /dev/null +++ b/src/features/sixSeven/ConfettiBurst.tsx @@ -0,0 +1,204 @@ +import {useEffect, useRef, useState} from 'react' +import {StyleSheet, useWindowDimensions, View} from 'react-native' +import Animated, { + Easing, + interpolate, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' + +import {Portal} from '#/components/Portal' + +const COLORS = [ + '#FF6B6B', + '#FFD93D', + '#6BCB77', + '#4D96FF', + '#FF6BD6', + '#FF9F43', + '#C56CF0', + '#18DCFF', +] + +const SPAWN_DURATION_MS = 1500 +const PARTICLE_LIFETIME_MS = 3000 +const SPAWN_INTERVAL_MS = 45 + +type Origin = 'top' | 'bottomLeft' | 'bottomRight' + +type Particle = { + id: number + color: string + startX: number + startY: number + endX: number + endY: number + size: number + spin: number +} + +function createRandomParticle( + id: number, + width: number, + height: number, +): Particle { + const origins: Origin[] = ['top', 'bottomLeft', 'bottomRight'] + const origin = origins[Math.floor(Math.random() * origins.length)]! + const color = COLORS[Math.floor(Math.random() * COLORS.length)]! + + if (origin === 'top') { + const startX = Math.random() * width + return { + id, + color, + startX, + startY: -24, + endX: startX + (Math.random() - 0.5) * 180, + endY: height + 32, + size: 6 + Math.random() * 8, + spin: (Math.random() - 0.5) * 1080, + } + } + + const spread = 0.55 + Math.random() * 0.45 + const arcHeight = height * (0.45 + Math.random() * 0.45) + + if (origin === 'bottomLeft') { + const startX = Math.random() * 48 + const startY = height + Math.random() * 24 + return { + id, + color, + startX, + startY, + endX: startX + width * spread * (0.35 + Math.random() * 0.65), + endY: startY - arcHeight, + size: 7 + Math.random() * 9, + spin: (Math.random() - 0.5) * 1260, + } + } + + const startX = width - Math.random() * 48 + const startY = height + Math.random() * 24 + return { + id, + color, + startX, + startY, + endX: startX - width * spread * (0.35 + Math.random() * 0.65), + endY: startY - arcHeight, + size: 7 + Math.random() * 9, + spin: (Math.random() - 0.5) * 1260, + } +} + +function spawnBatch( + width: number, + height: number, + startId: number, +): Particle[] { + const count = 4 + Math.floor(Math.random() * 4) + return Array.from({length: count}, (_, index) => + createRandomParticle(startId + index, width, height), + ) +} + +function ConfettiPiece({particle}: {particle: Particle}) { + const progress = useSharedValue(0) + + useEffect(() => { + progress.set(() => + withTiming(1, { + duration: PARTICLE_LIFETIME_MS, + easing: Easing.out(Easing.cubic), + }), + ) + }, [progress]) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: interpolate( + progress.get(), + [0, 1], + [particle.startX, particle.endX], + ), + }, + { + translateY: interpolate( + progress.get(), + [0, 1], + [particle.startY, particle.endY], + ), + }, + {rotate: `${particle.spin * progress.get()}deg`}, + ], + opacity: interpolate(progress.get(), [0, 0.06, 0.88, 1], [0, 1, 1, 0]), + })) + + return ( + + ) +} + +export function ConfettiBurst({onComplete}: {onComplete: () => void}) { + const {width, height} = useWindowDimensions() + const [particles, setParticles] = useState([]) + const nextId = useRef(0) + + useEffect(() => { + const spawn = () => { + const batch = spawnBatch(width, height, nextId.current) + nextId.current += batch.length + setParticles(prev => [...prev, ...batch]) + } + + spawn() + + const interval = setInterval(spawn, SPAWN_INTERVAL_MS) + const stopSpawning = setTimeout(() => { + clearInterval(interval) + }, SPAWN_DURATION_MS) + + const finish = setTimeout(() => { + onComplete() + }, SPAWN_DURATION_MS + PARTICLE_LIFETIME_MS) + + return () => { + clearInterval(interval) + clearTimeout(stopSpawning) + clearTimeout(finish) + } + }, [width, height, onComplete]) + + return ( + + + {particles.map(particle => ( + + ))} + + + ) +} + +const styles = StyleSheet.create({ + layer: { + zIndex: 9999, + elevation: 9999, + }, +}) diff --git a/src/features/sixSeven/SixSevenOutlet.tsx b/src/features/sixSeven/SixSevenOutlet.tsx new file mode 100644 index 000000000..59ef389ea --- /dev/null +++ b/src/features/sixSeven/SixSevenOutlet.tsx @@ -0,0 +1,30 @@ +import {useEffect, useState} from 'react' + +import {registerSixSevenControls} from '#/features/sixSeven/celebrate' +import {ConfettiBurst} from '#/features/sixSeven/ConfettiBurst' + +export function SixSevenOutlet() { + const [bursts, setBursts] = useState([]) + + useEffect(() => { + registerSixSevenControls({ + burst: () => { + setBursts(prev => [...prev, Date.now()]) + }, + }) + return () => registerSixSevenControls(null) + }, []) + + return ( + <> + {bursts.map(id => ( + { + setBursts(prev => prev.filter(burstId => burstId !== id)) + }} + /> + ))} + + ) +} diff --git a/src/features/sixSeven/celebrate.ts b/src/features/sixSeven/celebrate.ts new file mode 100644 index 000000000..cdbdcd369 --- /dev/null +++ b/src/features/sixSeven/celebrate.ts @@ -0,0 +1,34 @@ +import {AccessibilityInfo} from 'react-native' + +import * as Toast from '#/components/Toast' + +type SixSevenControls = { + burst: () => void +} + +let controls: SixSevenControls | null = null + +export function registerSixSevenControls(next: SixSevenControls | null) { + controls = next +} + +export function maybeCelebrateSixSevenLike({ + wasLiked, + likeCount, +}: { + wasLiked: boolean + likeCount: number | undefined | null +}) { + if (wasLiked) return + if ((likeCount ?? 0) + 1 !== 67) return + void celebrateSixSeven() +} + +export async function celebrateSixSeven() { + Toast.show('six seven', {type: 'success'}) + + const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled() + if (!reduceMotion) { + controls?.burst() + } +} diff --git a/src/features/sixSeven/index.ts b/src/features/sixSeven/index.ts new file mode 100644 index 000000000..274808465 --- /dev/null +++ b/src/features/sixSeven/index.ts @@ -0,0 +1,2 @@ +export {celebrateSixSeven, maybeCelebrateSixSevenLike} from '#/features/sixSeven/celebrate' +export {SixSevenOutlet} from '#/features/sixSeven/SixSevenOutlet' diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index 9d8d207e9..11c926d45 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -7,6 +7,7 @@ import { useQueryClient, } from '@tanstack/react-query' +import {maybeCelebrateSixSevenLike} from '#/features/sixSeven' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' import {updatePostShadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/types' @@ -170,12 +171,23 @@ export function usePostLikeMutationQueue( }) const queueLike = useCallback(() => { + maybeCelebrateSixSevenLike({ + wasLiked: Boolean(post.viewer?.like), + likeCount: post.likeCount, + }) + // optimistically update updatePostShadow(queryClient, postUri, { likeUri: 'pending', }) return queueToggle(true) - }, [queryClient, postUri, queueToggle]) + }, [ + post.likeCount, + post.viewer?.like, + queryClient, + postUri, + queueToggle, + ]) const queueUnlike = useCallback(() => { // optimistically update