@farcaster/snap 1.15.1 → 1.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -75,6 +75,49 @@ function ConfettiOverlay() {
75
75
  animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
76
76
  } }, id))), _jsx("style", { children: `@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${Math.random() > 0.5 ? "" : "-"}40px)}}` })] }));
77
77
  }
78
+ function SnapLoadingOverlay({ appearance, accentHex, active, }) {
79
+ const isDark = appearance === "dark";
80
+ const tint = isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.2)";
81
+ const trackColor = isDark
82
+ ? "rgba(255, 255, 255, 0.12)"
83
+ : "rgba(15, 23, 42, 0.1)";
84
+ return (_jsxs("div", { style: {
85
+ position: "absolute",
86
+ inset: 0,
87
+ display: "flex",
88
+ alignItems: "center",
89
+ justifyContent: "center",
90
+ zIndex: 10,
91
+ background: tint,
92
+ backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
93
+ WebkitBackdropFilter: active
94
+ ? "blur(10px) saturate(1.05)"
95
+ : "none",
96
+ opacity: active ? 1 : 0,
97
+ pointerEvents: active ? "auto" : "none",
98
+ transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
99
+ }, "aria-hidden": !active, "aria-busy": active ? true : undefined, "aria-live": active ? "polite" : undefined, "aria-label": active ? "Loading" : undefined, children: [_jsx("div", { "data-snap-loading-spinner": true, style: {
100
+ width: 30,
101
+ height: 30,
102
+ borderRadius: "50%",
103
+ border: `2.5px solid ${trackColor}`,
104
+ borderTopColor: accentHex,
105
+ opacity: 0.88,
106
+ animation: "snapViewSpin 0.75s linear infinite",
107
+ flexShrink: 0,
108
+ } }), _jsx("style", { children: `
109
+ @keyframes snapViewSpin {
110
+ to { transform: rotate(360deg); }
111
+ }
112
+ @media (prefers-reduced-motion: reduce) {
113
+ [data-snap-loading-spinner] {
114
+ animation: none;
115
+ border-top-color: ${accentHex};
116
+ opacity: 0.75;
117
+ }
118
+ }
119
+ ` })] }));
120
+ }
78
121
  const PALETTE = [
79
122
  "gray",
80
123
  "blue",
@@ -112,15 +155,25 @@ export function SnapView({ snap, handlers, loading = false, appearance = "dark",
112
155
  setPageKey((k) => k + 1);
113
156
  }, [spec]);
114
157
  const showConfetti = snap.effects?.includes("confetti");
158
+ // Increment key each time a new snap with confetti arrives so the overlay
159
+ // unmounts/remounts and restarts its animation on every trigger.
160
+ const confettiEpochRef = useRef(0);
161
+ const lastConfettiSnapRef = useRef(null);
162
+ if (showConfetti && snap !== lastConfettiSnapRef.current) {
163
+ confettiEpochRef.current++;
164
+ lastConfettiSnapRef.current = snap;
165
+ }
166
+ const accentName = snap.theme?.accent ?? "purple";
167
+ const accentHex = useMemo(() => resolveSnapPaletteHex(accentName, appearance), [accentName, appearance]);
115
168
  const previewSurfaceStyle = useMemo(() => {
116
169
  const vars = {};
117
170
  for (const c of PALETTE)
118
171
  vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
119
172
  return {
120
- ...snapPreviewPrimaryCssProperties(snap.theme?.accent ?? "purple", appearance),
173
+ ...snapPreviewPrimaryCssProperties(accentName, appearance),
121
174
  ...vars,
122
175
  };
123
- }, [snap.theme?.accent, appearance]);
176
+ }, [accentName, appearance]);
124
177
  const handleAction = useCallback((name, params) => {
125
178
  const inputs = (stateRef.current.inputs ?? {});
126
179
  const p = (params ?? {});
@@ -156,9 +209,7 @@ export function SnapView({ snap, handlers, loading = false, appearance = "dark",
156
209
  handlers.send_token({
157
210
  token: String(p.token ?? ""),
158
211
  amount: p.amount ? String(p.amount) : undefined,
159
- recipientFid: p.recipientFid
160
- ? Number(p.recipientFid)
161
- : undefined,
212
+ recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
162
213
  recipientAddress: p.recipientAddress
163
214
  ? String(p.recipientAddress)
164
215
  : undefined,
@@ -174,21 +225,7 @@ export function SnapView({ snap, handlers, loading = false, appearance = "dark",
174
225
  break;
175
226
  }
176
227
  }, [handlers]);
177
- return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}), _jsx("div", { style: {
178
- position: "absolute",
179
- inset: 0,
180
- display: "flex",
181
- alignItems: "center",
182
- justifyContent: "center",
183
- zIndex: 10,
184
- fontSize: 14,
185
- color: appearance === "dark" ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)",
186
- background: appearance === "dark" ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.5)",
187
- backdropFilter: loading ? "blur(8px)" : "blur(0px)",
188
- opacity: loading ? 1 : 0,
189
- pointerEvents: loading ? "auto" : "none",
190
- transition: "opacity 0.3s ease, backdrop-filter 0.3s ease",
191
- }, children: "Loading..." }), _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
228
+ return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}, confettiEpochRef.current), _jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading }), _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
192
229
  applyStatePaths(stateRef.current, changes);
193
230
  }, onAction: handleAction }, pageKey) }) })] }));
194
231
  }
@@ -0,0 +1 @@
1
+ export declare function ConfettiOverlay(): import("react").JSX.Element;
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import { Animated, StyleSheet, View, useWindowDimensions, } from "react-native";
4
+ const CONFETTI_COLORS = [
5
+ "#8B5CF6",
6
+ "#EC4899",
7
+ "#3B82F6",
8
+ "#10B981",
9
+ "#F59E0B",
10
+ "#EF4444",
11
+ "#06B6D4",
12
+ ];
13
+ export function ConfettiOverlay() {
14
+ const { width, height } = useWindowDimensions();
15
+ const pieces = useMemo(() => Array.from({ length: 80 }, (_, i) => ({
16
+ id: i,
17
+ left: Math.random() * width,
18
+ delay: Math.random() * 1200,
19
+ duration: 2500 + Math.random() * 2000,
20
+ color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
21
+ size: 6 + Math.random() * 8,
22
+ startRotation: Math.random() * 360,
23
+ driftX: (Math.random() > 0.5 ? 1 : -1) * Math.random() * 40,
24
+ })),
25
+ // width captured once on mount; intentional stable dep
26
+ // eslint-disable-next-line react-hooks/exhaustive-deps
27
+ []);
28
+ const anims = useRef(pieces.map(() => ({
29
+ translateY: new Animated.Value(-20),
30
+ opacity: new Animated.Value(1),
31
+ rotate: new Animated.Value(0),
32
+ translateX: new Animated.Value(0),
33
+ }))).current;
34
+ useEffect(() => {
35
+ const animations = pieces.map((piece, i) => {
36
+ const anim = anims[i];
37
+ anim.translateY.setValue(-20);
38
+ anim.opacity.setValue(1);
39
+ anim.rotate.setValue(0);
40
+ anim.translateX.setValue(0);
41
+ return Animated.sequence([
42
+ Animated.delay(piece.delay),
43
+ Animated.parallel([
44
+ Animated.timing(anim.translateY, {
45
+ toValue: height + 20,
46
+ duration: piece.duration,
47
+ useNativeDriver: true,
48
+ }),
49
+ Animated.timing(anim.opacity, {
50
+ toValue: 0,
51
+ duration: piece.duration,
52
+ useNativeDriver: true,
53
+ }),
54
+ Animated.timing(anim.rotate, {
55
+ toValue: 720,
56
+ duration: piece.duration,
57
+ useNativeDriver: true,
58
+ }),
59
+ Animated.timing(anim.translateX, {
60
+ toValue: piece.driftX,
61
+ duration: piece.duration,
62
+ useNativeDriver: true,
63
+ }),
64
+ ]),
65
+ ]);
66
+ });
67
+ const composite = Animated.parallel(animations);
68
+ composite.start();
69
+ return () => composite.stop();
70
+ }, [pieces, anims, height]);
71
+ return (_jsx(View, { style: [StyleSheet.absoluteFill, styles.container], pointerEvents: "none", children: pieces.map((piece, i) => {
72
+ const anim = anims[i];
73
+ const rotate = anim.rotate.interpolate({
74
+ inputRange: [0, 720],
75
+ outputRange: [
76
+ `${piece.startRotation}deg`,
77
+ `${piece.startRotation + 720}deg`,
78
+ ],
79
+ });
80
+ return (_jsx(Animated.View, { style: [
81
+ styles.piece,
82
+ {
83
+ left: piece.left,
84
+ width: piece.size,
85
+ height: piece.size * 0.6,
86
+ backgroundColor: piece.color,
87
+ opacity: anim.opacity,
88
+ transform: [
89
+ { translateY: anim.translateY },
90
+ { translateX: anim.translateX },
91
+ { rotate },
92
+ ],
93
+ },
94
+ ] }, piece.id));
95
+ }) }));
96
+ }
97
+ const styles = StyleSheet.create({
98
+ container: {
99
+ overflow: "hidden",
100
+ },
101
+ piece: {
102
+ position: "absolute",
103
+ top: 0,
104
+ borderRadius: 2,
105
+ },
106
+ });
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
3
3
  import { SnapCatalogView } from "./catalog-renderer.js";
4
- import { SnapThemeProvider, useSnapTheme } from "./theme.js";
4
+ import { SnapThemeProvider, useSnapTheme, } from "./theme.js";
5
5
  import { hexToRgba } from "./use-snap-palette.js";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { ActivityIndicator, StyleSheet, View } from "react-native";
8
+ import { ConfettiOverlay } from "./confetti-overlay.js";
8
9
  import { DEFAULT_THEME_ACCENT, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, } from "@farcaster/snap";
9
10
  // ─── Re-exports ───────────────────────────────────────
10
11
  export { useSnapTheme, hexToRgba };
@@ -42,7 +43,9 @@ function applyStatePaths(model, changes) {
42
43
  }
43
44
  function resolveAccentHex(accent, appearance) {
44
45
  const map = appearance === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
45
- const name = accent && Object.hasOwn(map, accent) ? accent : DEFAULT_THEME_ACCENT;
46
+ const name = accent && Object.hasOwn(map, accent)
47
+ ? accent
48
+ : DEFAULT_THEME_ACCENT;
46
49
  return map[name];
47
50
  }
48
51
  // ─── SnapView ─────────────────────────────────────────
@@ -50,6 +53,15 @@ function SnapViewInner({ snap, handlers, loading = false, }) {
50
53
  const { mode } = useSnapTheme();
51
54
  const spec = snap.ui;
52
55
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
56
+ const showConfetti = snap.effects?.includes("confetti");
57
+ // Increment key each time a new snap with confetti arrives so the overlay
58
+ // unmounts/remounts and restarts its animation on every trigger.
59
+ const confettiEpochRef = useRef(0);
60
+ const lastConfettiSnapRef = useRef(null);
61
+ if (showConfetti && snap !== lastConfettiSnapRef.current) {
62
+ confettiEpochRef.current++;
63
+ lastConfettiSnapRef.current = snap;
64
+ }
53
65
  const initialState = useMemo(() => ({
54
66
  ...(spec.state ?? {}),
55
67
  inputs: { ...(spec.state?.inputs ?? {}) },
@@ -117,7 +129,9 @@ function SnapViewInner({ snap, handlers, loading = false, }) {
117
129
  token: String(p.token ?? ""),
118
130
  amount: p.amount ? String(p.amount) : undefined,
119
131
  recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
120
- recipientAddress: p.recipientAddress ? String(p.recipientAddress) : undefined,
132
+ recipientAddress: p.recipientAddress
133
+ ? String(p.recipientAddress)
134
+ : undefined,
121
135
  });
122
136
  break;
123
137
  case "swap_token":
@@ -130,12 +144,12 @@ function SnapViewInner({ snap, handlers, loading = false, }) {
130
144
  break;
131
145
  }
132
146
  }, []);
133
- return (_jsxs(View, { style: styles.container, children: [loading && (_jsx(View, { style: [
147
+ return (_jsxs(View, { style: styles.container, children: [loading ? (_jsx(View, { style: [
134
148
  styles.overlay,
135
149
  {
136
- backgroundColor: mode === "dark" ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.5)",
150
+ backgroundColor: mode === "dark" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.2)",
137
151
  },
138
- ], children: _jsx(ActivityIndicator, { size: "large", color: accentHex }) })), _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
152
+ ], children: _jsx(ActivityIndicator, { size: "large", color: accentHex }) })) : null, showConfetti ? _jsx(ConfettiOverlay, {}, confettiEpochRef.current) : null, _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
139
153
  applyStatePaths(stateRef.current, changes);
140
154
  }, onAction: handleAction }, pageKey)] }));
141
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "1.15.1",
3
+ "version": "1.15.2",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,10 +50,7 @@ export type SnapActionHandlers = {
50
50
  recipientFid?: number;
51
51
  recipientAddress?: string;
52
52
  }) => void;
53
- swap_token: (params: {
54
- sellToken?: string;
55
- buyToken?: string;
56
- }) => void;
53
+ swap_token: (params: { sellToken?: string; buyToken?: string }) => void;
57
54
  };
58
55
 
59
56
  // ─── Internal helpers ──────────────────────────────────
@@ -144,7 +141,76 @@ function ConfettiOverlay() {
144
141
  }}
145
142
  />
146
143
  ))}
147
- <style>{`@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${Math.random() > 0.5 ? "" : "-"}40px)}}`}</style>
144
+ <style>{`@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${
145
+ Math.random() > 0.5 ? "" : "-"
146
+ }40px)}}`}</style>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ function SnapLoadingOverlay({
152
+ appearance,
153
+ accentHex,
154
+ active,
155
+ }: {
156
+ appearance: "light" | "dark";
157
+ accentHex: string;
158
+ active: boolean;
159
+ }) {
160
+ const isDark = appearance === "dark";
161
+ const tint = isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.2)";
162
+ const trackColor = isDark
163
+ ? "rgba(255, 255, 255, 0.12)"
164
+ : "rgba(15, 23, 42, 0.1)";
165
+
166
+ return (
167
+ <div
168
+ style={{
169
+ position: "absolute",
170
+ inset: 0,
171
+ display: "flex",
172
+ alignItems: "center",
173
+ justifyContent: "center",
174
+ zIndex: 10,
175
+ background: tint,
176
+ backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
177
+ WebkitBackdropFilter: active
178
+ ? "blur(10px) saturate(1.05)"
179
+ : "none",
180
+ opacity: active ? 1 : 0,
181
+ pointerEvents: active ? "auto" : "none",
182
+ transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
183
+ }}
184
+ aria-hidden={!active}
185
+ aria-busy={active ? true : undefined}
186
+ aria-live={active ? "polite" : undefined}
187
+ aria-label={active ? "Loading" : undefined}
188
+ >
189
+ <div
190
+ data-snap-loading-spinner
191
+ style={{
192
+ width: 30,
193
+ height: 30,
194
+ borderRadius: "50%",
195
+ border: `2.5px solid ${trackColor}`,
196
+ borderTopColor: accentHex,
197
+ opacity: 0.88,
198
+ animation: "snapViewSpin 0.75s linear infinite",
199
+ flexShrink: 0,
200
+ }}
201
+ />
202
+ <style>{`
203
+ @keyframes snapViewSpin {
204
+ to { transform: rotate(360deg); }
205
+ }
206
+ @media (prefers-reduced-motion: reduce) {
207
+ [data-snap-loading-spinner] {
208
+ animation: none;
209
+ border-top-color: ${accentHex};
210
+ opacity: 0.75;
211
+ }
212
+ }
213
+ `}</style>
148
214
  </div>
149
215
  );
150
216
  }
@@ -174,10 +240,7 @@ export function SnapView({
174
240
  appearance?: "light" | "dark";
175
241
  }) {
176
242
  const spec = snap.ui;
177
- const initialState = useMemo(
178
- () => spec.state ?? { inputs: {} },
179
- [spec],
180
- );
243
+ const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
181
244
 
182
245
  const stateRef = useRef<Record<string, unknown>>(initialState);
183
246
 
@@ -207,18 +270,31 @@ export function SnapView({
207
270
 
208
271
  const showConfetti = snap.effects?.includes("confetti");
209
272
 
273
+ // Increment key each time a new snap with confetti arrives so the overlay
274
+ // unmounts/remounts and restarts its animation on every trigger.
275
+ const confettiEpochRef = useRef(0);
276
+ const lastConfettiSnapRef = useRef<typeof snap | null>(null);
277
+ if (showConfetti && snap !== lastConfettiSnapRef.current) {
278
+ confettiEpochRef.current++;
279
+ lastConfettiSnapRef.current = snap;
280
+ }
281
+
282
+ const accentName = snap.theme?.accent ?? "purple";
283
+
284
+ const accentHex = useMemo(
285
+ () => resolveSnapPaletteHex(accentName, appearance),
286
+ [accentName, appearance],
287
+ );
288
+
210
289
  const previewSurfaceStyle = useMemo(() => {
211
290
  const vars: Record<string, string> = {};
212
291
  for (const c of PALETTE)
213
292
  vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
214
293
  return {
215
- ...snapPreviewPrimaryCssProperties(
216
- snap.theme?.accent ?? "purple",
217
- appearance,
218
- ),
294
+ ...snapPreviewPrimaryCssProperties(accentName, appearance),
219
295
  ...vars,
220
296
  } as CSSProperties;
221
- }, [snap.theme?.accent, appearance]);
297
+ }, [accentName, appearance]);
222
298
 
223
299
  const handleAction = useCallback(
224
300
  (name: unknown, params: unknown) => {
@@ -259,9 +335,7 @@ export function SnapView({
259
335
  handlers.send_token({
260
336
  token: String(p.token ?? ""),
261
337
  amount: p.amount ? String(p.amount) : undefined,
262
- recipientFid: p.recipientFid
263
- ? Number(p.recipientFid)
264
- : undefined,
338
+ recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
265
339
  recipientAddress: p.recipientAddress
266
340
  ? String(p.recipientAddress)
267
341
  : undefined,
@@ -282,29 +356,18 @@ export function SnapView({
282
356
 
283
357
  return (
284
358
  <div style={{ position: "relative", width: "100%" }}>
285
- {showConfetti && <ConfettiOverlay />}
286
- <div
287
- style={{
288
- position: "absolute",
289
- inset: 0,
290
- display: "flex",
291
- alignItems: "center",
292
- justifyContent: "center",
293
- zIndex: 10,
294
- fontSize: 14,
295
- color: appearance === "dark" ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)",
296
- background: appearance === "dark" ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.5)",
297
- backdropFilter: loading ? "blur(8px)" : "blur(0px)",
298
- opacity: loading ? 1 : 0,
299
- pointerEvents: loading ? "auto" : "none",
300
- transition: "opacity 0.3s ease, backdrop-filter 0.3s ease",
301
- }}
302
- >
303
- Loading...
304
- </div>
359
+ {showConfetti && <ConfettiOverlay key={confettiEpochRef.current} />}
360
+ <SnapLoadingOverlay
361
+ appearance={appearance}
362
+ accentHex={accentHex}
363
+ active={loading}
364
+ />
305
365
 
306
366
  <div style={previewSurfaceStyle}>
307
- <SnapPreviewAccentProvider pageAccent={snap.theme?.accent} appearance={appearance}>
367
+ <SnapPreviewAccentProvider
368
+ pageAccent={snap.theme?.accent}
369
+ appearance={appearance}
370
+ >
308
371
  <SnapCatalogView
309
372
  key={pageKey}
310
373
  spec={spec}
@@ -0,0 +1,134 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import {
3
+ Animated,
4
+ StyleSheet,
5
+ View,
6
+ useWindowDimensions,
7
+ } from "react-native";
8
+
9
+ const CONFETTI_COLORS = [
10
+ "#8B5CF6",
11
+ "#EC4899",
12
+ "#3B82F6",
13
+ "#10B981",
14
+ "#F59E0B",
15
+ "#EF4444",
16
+ "#06B6D4",
17
+ ];
18
+
19
+ export function ConfettiOverlay() {
20
+ const { width, height } = useWindowDimensions();
21
+
22
+ const pieces = useMemo(
23
+ () =>
24
+ Array.from({ length: 80 }, (_, i) => ({
25
+ id: i,
26
+ left: Math.random() * width,
27
+ delay: Math.random() * 1200,
28
+ duration: 2500 + Math.random() * 2000,
29
+ color:
30
+ CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)]!,
31
+ size: 6 + Math.random() * 8,
32
+ startRotation: Math.random() * 360,
33
+ driftX: (Math.random() > 0.5 ? 1 : -1) * Math.random() * 40,
34
+ })),
35
+ // width captured once on mount; intentional stable dep
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ [],
38
+ );
39
+
40
+ const anims = useRef(
41
+ pieces.map(() => ({
42
+ translateY: new Animated.Value(-20),
43
+ opacity: new Animated.Value(1),
44
+ rotate: new Animated.Value(0),
45
+ translateX: new Animated.Value(0),
46
+ })),
47
+ ).current;
48
+
49
+ useEffect(() => {
50
+ const animations = pieces.map((piece, i) => {
51
+ const anim = anims[i]!;
52
+ anim.translateY.setValue(-20);
53
+ anim.opacity.setValue(1);
54
+ anim.rotate.setValue(0);
55
+ anim.translateX.setValue(0);
56
+
57
+ return Animated.sequence([
58
+ Animated.delay(piece.delay),
59
+ Animated.parallel([
60
+ Animated.timing(anim.translateY, {
61
+ toValue: height + 20,
62
+ duration: piece.duration,
63
+ useNativeDriver: true,
64
+ }),
65
+ Animated.timing(anim.opacity, {
66
+ toValue: 0,
67
+ duration: piece.duration,
68
+ useNativeDriver: true,
69
+ }),
70
+ Animated.timing(anim.rotate, {
71
+ toValue: 720,
72
+ duration: piece.duration,
73
+ useNativeDriver: true,
74
+ }),
75
+ Animated.timing(anim.translateX, {
76
+ toValue: piece.driftX,
77
+ duration: piece.duration,
78
+ useNativeDriver: true,
79
+ }),
80
+ ]),
81
+ ]);
82
+ });
83
+
84
+ const composite = Animated.parallel(animations);
85
+ composite.start();
86
+ return () => composite.stop();
87
+ }, [pieces, anims, height]);
88
+
89
+ return (
90
+ <View style={[StyleSheet.absoluteFill, styles.container]} pointerEvents="none">
91
+ {pieces.map((piece, i) => {
92
+ const anim = anims[i]!;
93
+ const rotate = anim.rotate.interpolate({
94
+ inputRange: [0, 720],
95
+ outputRange: [
96
+ `${piece.startRotation}deg`,
97
+ `${piece.startRotation + 720}deg`,
98
+ ],
99
+ });
100
+ return (
101
+ <Animated.View
102
+ key={piece.id}
103
+ style={[
104
+ styles.piece,
105
+ {
106
+ left: piece.left,
107
+ width: piece.size,
108
+ height: piece.size * 0.6,
109
+ backgroundColor: piece.color,
110
+ opacity: anim.opacity,
111
+ transform: [
112
+ { translateY: anim.translateY },
113
+ { translateX: anim.translateX },
114
+ { rotate },
115
+ ],
116
+ },
117
+ ]}
118
+ />
119
+ );
120
+ })}
121
+ </View>
122
+ );
123
+ }
124
+
125
+ const styles = StyleSheet.create({
126
+ container: {
127
+ overflow: "hidden",
128
+ },
129
+ piece: {
130
+ position: "absolute",
131
+ top: 0,
132
+ borderRadius: 2,
133
+ },
134
+ });
@@ -1,10 +1,15 @@
1
1
  import type { Spec } from "@json-render/core";
2
2
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
3
3
  import { SnapCatalogView } from "./catalog-renderer";
4
- import { SnapThemeProvider, useSnapTheme, type SnapNativeColors } from "./theme";
4
+ import {
5
+ SnapThemeProvider,
6
+ useSnapTheme,
7
+ type SnapNativeColors,
8
+ } from "./theme";
5
9
  import { hexToRgba } from "./use-snap-palette";
6
10
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
11
  import { ActivityIndicator, StyleSheet, View } from "react-native";
12
+ import { ConfettiOverlay } from "./confetti-overlay";
8
13
  import {
9
14
  DEFAULT_THEME_ACCENT,
10
15
  PALETTE_LIGHT_HEX,
@@ -47,10 +52,7 @@ export type SnapActionHandlers = {
47
52
  recipientFid?: number;
48
53
  recipientAddress?: string;
49
54
  }) => void;
50
- swap_token: (params: {
51
- sellToken?: string;
52
- buyToken?: string;
53
- }) => void;
55
+ swap_token: (params: { sellToken?: string; buyToken?: string }) => void;
54
56
  };
55
57
 
56
58
  // ─── Re-exports ───────────────────────────────────────
@@ -94,9 +96,15 @@ function applyStatePaths(
94
96
  }
95
97
  }
96
98
 
97
- function resolveAccentHex(accent: string | undefined, appearance: "light" | "dark"): string {
99
+ function resolveAccentHex(
100
+ accent: string | undefined,
101
+ appearance: "light" | "dark",
102
+ ): string {
98
103
  const map = appearance === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
99
- const name = accent && Object.hasOwn(map, accent) ? (accent as PaletteColor) : DEFAULT_THEME_ACCENT;
104
+ const name =
105
+ accent && Object.hasOwn(map, accent)
106
+ ? (accent as PaletteColor)
107
+ : DEFAULT_THEME_ACCENT;
100
108
  return map[name];
101
109
  }
102
110
 
@@ -115,6 +123,17 @@ function SnapViewInner({
115
123
  const spec = snap.ui;
116
124
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
117
125
 
126
+ const showConfetti = snap.effects?.includes("confetti");
127
+
128
+ // Increment key each time a new snap with confetti arrives so the overlay
129
+ // unmounts/remounts and restarts its animation on every trigger.
130
+ const confettiEpochRef = useRef(0);
131
+ const lastConfettiSnapRef = useRef<typeof snap | null>(null);
132
+ if (showConfetti && snap !== lastConfettiSnapRef.current) {
133
+ confettiEpochRef.current++;
134
+ lastConfettiSnapRef.current = snap;
135
+ }
136
+
118
137
  const initialState = useMemo(
119
138
  () => ({
120
139
  ...(spec.state ?? {}),
@@ -191,7 +210,9 @@ function SnapViewInner({
191
210
  token: String(p.token ?? ""),
192
211
  amount: p.amount ? String(p.amount) : undefined,
193
212
  recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
194
- recipientAddress: p.recipientAddress ? String(p.recipientAddress) : undefined,
213
+ recipientAddress: p.recipientAddress
214
+ ? String(p.recipientAddress)
215
+ : undefined,
195
216
  });
196
217
  break;
197
218
  case "swap_token":
@@ -207,19 +228,20 @@ function SnapViewInner({
207
228
 
208
229
  return (
209
230
  <View style={styles.container}>
210
- {loading && (
231
+ {loading ? (
211
232
  <View
212
233
  style={[
213
234
  styles.overlay,
214
235
  {
215
236
  backgroundColor:
216
- mode === "dark" ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.5)",
237
+ mode === "dark" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.2)",
217
238
  },
218
239
  ]}
219
240
  >
220
241
  <ActivityIndicator size="large" color={accentHex} />
221
242
  </View>
222
- )}
243
+ ) : null}
244
+ {showConfetti ? <ConfettiOverlay key={confettiEpochRef.current} /> : null}
223
245
  <SnapCatalogView
224
246
  key={pageKey}
225
247
  spec={spec}