Modernize confetti

This commit is contained in:
ponas committed 2026-07-27 03:40:06 +02:00
1 parent 6922718bee
commit ad82544fbb
5 files changed
+179 -53

No files matched your search

-1
View File
@@ -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/)
+23 -10
View File
@@ -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 };
});
+81 -17
View File
@@ -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",
+2 -2
View File
@@ -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",
+73 -23
View File
@@ -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<ConfettiMethods | null>;
burstRef: React.RefObject<PIConfettiMethods | null>;
containerRef: React.RefObject<React.ComponentRef<typeof View> | null>;
onRainStart: () => void;
onRainEnd: () => void;
}) => {
if (!user.is_member) {
return null;
}
// Confetti!!!!
return <Confetti ref={confettiRef} confettiCount={Number.MAX_SAFE_INTEGER} />;
return (
<View ref={containerRef} pointerEvents="none" style={styles.confetti}>
<Confetti
ref={confettiRef}
autoplay={false}
count={400}
gravity={0.24}
easing={Easing.linear}
onAnimationStart={onRainStart}
onAnimationEnd={onRainEnd}
/>
<PIConfetti ref={burstRef} autoplay={false} fadeOutOnEnd>
<PIConfetti.Origin blastPosition="center" count={80} initialSpeed={2} />
</PIConfetti>
</View>
);
};
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<Confetti>(null);
const confettiRef = useRef<ConfettiMethods>(null);
const burstRef = useRef<PIConfettiMethods>(null);
const confettiContainerRef = useRef<React.ComponentRef<typeof View>>(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 (
<>
<UserConfetti user={user} confettiRef={confettiRef} />
<UserConfetti
user={user}
confettiRef={confettiRef}
burstRef={burstRef}
containerRef={confettiContainerRef}
onRainStart={onRainStart}
onRainEnd={onRainEnd}
/>
<View style={styles.container}>
<ScrollView refreshControl={<RefreshControl refreshing={isFetchingUserData} onRefresh={fetchUser} />}>
<View style={[theme.card, { paddingVertical: 16, backgroundColor: 'transparent' }]}>
@@ -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,
},