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