From ad82544fbbce761334b55b1d1fa0e3c79f5da859 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Mon, 27 Jul 2026 03:40:06 +0200 Subject: [PATCH] Modernize confetti --- README.md | 1 - jest.setup.js | 33 +++++++---- package-lock.json | 98 ++++++++++++++++++++++++++------ package.json | 4 +- src/modules/membership/Proof.tsx | 96 +++++++++++++++++++++++-------- 5 files changed, 179 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 3ac7d36..b749494 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,6 @@ history, code and binary blobs related to React Native functionality filtered ou - replace [moment](https://momentjs.com/docs/#/-project-status/) (maintenance mode) with `date-fns` - replace unmaintained UI packages: - - `react-native-confetti` (last release 2017, mocked out in tests) - `react-native-simple-radio-button` (react-native-ui-lib already ships RadioButton/RadioGroup) - migrate ESLint 8 + airbnb config (both EOL) to ESLint 9 flat config or [eslint-config-expo](https://docs.expo.dev/guides/using-eslint/) diff --git a/jest.setup.js b/jest.setup.js index c07085a..aa9dd89 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -37,16 +37,29 @@ jest.mock('expo-notifications', () => ({ scheduleNotificationAsync: jest.fn(async () => 'notification-id'), })); -// Visual only, and old enough that it fights with the test renderer. -jest.mock('react-native-confetti', () => { +// Visual only, and Skia's native canvas isn't available in Jest. +jest.mock('react-native-fast-confetti', () => { const React = require('react'); - return class Confetti extends React.Component { - startConfetti() {} - - stopConfetti() {} - - render() { - return null; - } + const Confetti = React.forwardRef(function Confetti(props, ref) { + React.useImperativeHandle(ref, () => ({ + restart() {}, + pause() {}, + resume() {}, + reset() {}, + })); + return null; + }); + const PIConfetti = React.forwardRef(function PIConfetti(props, ref) { + React.useImperativeHandle(ref, () => ({ + restart() {}, + pause() {}, + resume() {}, + reset() {}, + })); + return null; + }); + PIConfetti.Origin = function Origin() { + return null; }; + return { Confetti, PIConfetti }; }); diff --git a/package-lock.json b/package-lock.json index 2347c17..d7582c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@react-navigation/native": "^7.1.17", "@react-navigation/native-stack": "^7.3.26", "@sentry/react-native": "~7.11.0", + "@shopify/react-native-skia": "2.4.18", "@stripe/stripe-react-native": "0.63.0", "email-validator": "^2.0.4", "expo": "^55.0.0", @@ -29,7 +30,7 @@ "react": "19.2.0", "react-hook-form": "^7.52.1", "react-native": "0.83.6", - "react-native-confetti": "^0.1.0", + "react-native-fast-confetti": "^2.0.2", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.0", @@ -49,7 +50,6 @@ "@testing-library/react-native": "^13.3.3", "@types/jest": "^29.5.14", "@types/react": "~19.2.10", - "@types/react-native-confetti": "^0.1.4", "@types/react-native-simple-radio-button": "^2.7.7", "eslint": "^8.57.0", "eslint-config-airbnb": "^19.0.4", @@ -3776,6 +3776,33 @@ "node": ">=18" } }, + "node_modules/@shopify/react-native-skia": { + "version": "2.4.18", + "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.18.tgz", + "integrity": "sha512-/AB/mvb2dGSQVIJTxyG9ZMFn2PjwWlVcFxHU4TV+K25LCU49ntJXl4xda2ghXxdENMggKgO9R5pA+P/AQ0UUrA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "canvaskit-wasm": "0.40.0", + "react-reconciler": "0.31.0" + }, + "bin": { + "setup-skia-web": "scripts/setup-canvaskit.js" + }, + "peerDependencies": { + "react": ">=19.0", + "react-native": ">=0.78", + "react-native-reanimated": ">=3.19.1" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + } + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.12", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", @@ -4297,16 +4324,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/react-native-confetti": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@types/react-native-confetti/-/react-native-confetti-0.1.4.tgz", - "integrity": "sha512-StgW1o0dw8ZLCVZ7v6ZVOBu+RYT+PFjtQAhF9k7uLn/avor4UtKhB9cDrF377+JfRw0aHeo3gg9dawZNzM/jOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/react-native-simple-radio-button": { "version": "2.7.7", "resolved": "https://registry.npmjs.org/@types/react-native-simple-radio-button/-/react-native-simple-radio-button-2.7.7.tgz", @@ -4927,6 +4944,12 @@ "win32" ] }, + "node_modules/@webgpu/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", + "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", + "license": "BSD-3-Clause" + }, "node_modules/@wry/caches": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", @@ -5918,6 +5941,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvaskit-wasm": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", + "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -14291,11 +14323,22 @@ } } }, - "node_modules/react-native-confetti": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/react-native-confetti/-/react-native-confetti-0.1.0.tgz", - "integrity": "sha512-+cXJvnPXKc0i/eTQcuzcjrlfIxmn/xFw4UyuVKQOWNK6BfWABvO6Ms6Zp6ivF1E8Vxt2vJkzJlNyoBvMh9FxKA==", - "license": "MIT" + "node_modules/react-native-fast-confetti": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-native-fast-confetti/-/react-native-fast-confetti-2.0.2.tgz", + "integrity": "sha512-g/so5kYCQFMFNuD3ui10hMCXZTA8pPzOhwUJgzfslxI2WHuYb4SjdOxV0I9xPv7ts8Sqch6bS7oCJZMX2DM0Yw==", + "license": "MIT", + "workspaces": [ + "example", + "kitchen-sink" + ], + "peerDependencies": { + "@shopify/react-native-skia": ">=2.0.0 <3", + "react": "*", + "react-native": "*", + "react-native-reanimated": ">=4.1.0 <5", + "react-native-worklets": ">=0.7.0 <1" + } }, "node_modules/react-native-gesture-handler": { "version": "2.30.1", @@ -14596,6 +14639,27 @@ } } }, + "node_modules/react-reconciler": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", + "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", diff --git a/package.json b/package.json index e388506..5b04d76 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@react-navigation/native": "^7.1.17", "@react-navigation/native-stack": "^7.3.26", "@sentry/react-native": "~7.11.0", + "@shopify/react-native-skia": "2.4.18", "@stripe/stripe-react-native": "0.63.0", "email-validator": "^2.0.4", "expo": "^55.0.0", @@ -36,7 +37,7 @@ "react": "19.2.0", "react-hook-form": "^7.52.1", "react-native": "0.83.6", - "react-native-confetti": "^0.1.0", + "react-native-fast-confetti": "^2.0.2", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.0", @@ -56,7 +57,6 @@ "@testing-library/react-native": "^13.3.3", "@types/jest": "^29.5.14", "@types/react": "~19.2.10", - "@types/react-native-confetti": "^0.1.4", "@types/react-native-simple-radio-button": "^2.7.7", "eslint": "^8.57.0", "eslint-config-airbnb": "^19.0.4", diff --git a/src/modules/membership/Proof.tsx b/src/modules/membership/Proof.tsx index b6dfcad..4821d3f 100644 --- a/src/modules/membership/Proof.tsx +++ b/src/modules/membership/Proof.tsx @@ -1,5 +1,5 @@ import * as Notifications from 'expo-notifications'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Image, ScrollView, @@ -13,7 +13,9 @@ import { GestureResponderEvent, Switch, } from 'react-native'; -import Confetti from 'react-native-confetti'; +import { Confetti, PIConfetti } from 'react-native-fast-confetti'; +import type { ConfettiMethods, PIConfettiMethods } from 'react-native-fast-confetti'; +import { Easing } from 'react-native-reanimated'; import Button from '@/components/Button'; import RadioButton from '@/components/RadioButton'; @@ -130,14 +132,42 @@ const PurchaseButton = ({ ); }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const UserConfetti = ({ user, confettiRef }: { user: UserType; confettiRef: any }) => { +const UserConfetti = ({ + user, + confettiRef, + burstRef, + containerRef, + onRainStart, + onRainEnd, +}: { + user: UserType; + confettiRef: React.RefObject; + burstRef: React.RefObject; + containerRef: React.RefObject | null>; + onRainStart: () => void; + onRainEnd: () => void; +}) => { if (!user.is_member) { return null; } // Confetti!!!! - return ; + return ( + + + + + + + ); }; const Logo = ({ user }: { user: UserType }) => { if (!user.is_member) { @@ -216,8 +246,10 @@ interface ProofProps { onDeletePress: () => void; } const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }: ProofProps) => { - const CONFETTI_TIMEOUT = 15000; - const confettiRef = useRef(null); + const confettiRef = useRef(null); + const burstRef = useRef(null); + const confettiContainerRef = useRef>(null); + const isRaining = useRef(false); const { paymentLoading, isLoadingMembershipType, @@ -231,22 +263,25 @@ const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }: fetchUser, }); - useEffect(() => { - const currentRef = confettiRef.current; - return () => { - if (currentRef) { - currentRef.stopConfetti(); - } - }; - }); + const onRainStart = useCallback(() => { + isRaining.current = true; + }, []); + const onRainEnd = useCallback(() => { + isRaining.current = false; + }, []); - const onMembershipPress = () => { - confettiRef.current?.startConfetti(); - setTimeout(() => { - if (confettiRef && confettiRef.current) { - confettiRef.current.stopConfetti(); - } - }, CONFETTI_TIMEOUT); + const onMembershipPress = (event: GestureResponderEvent) => { + const { pageX, pageY } = event.nativeEvent; + // measure() reports pageX/pageY in the same coordinate space as the touch, + // which measureInWindow does not on Android. + confettiContainerRef.current?.measure((x, y, width, height, originX, originY) => { + burstRef.current?.restart({ + origins: [{ blastPosition: { x: pageX - originX, y: pageY - originY } }], + }); + }); + if (!isRaining.current) { + confettiRef.current?.restart(); + } }; if (!user) { @@ -259,7 +294,14 @@ const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }: return ( <> - + }> @@ -302,6 +344,14 @@ const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }: export default Proof; const styles = StyleSheet.create({ + confetti: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 1, + }, membershipStatus: { marginVertical: 16, },