@farcaster/snap 1.15.0 → 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.
- package/dist/react/accent-context.d.ts +3 -1
- package/dist/react/accent-context.js +7 -4
- package/dist/react/hooks/use-snap-colors.js +2 -3
- package/dist/react/index.js +57 -20
- package/dist/react-native/confetti-overlay.d.ts +1 -0
- package/dist/react-native/confetti-overlay.js +106 -0
- package/dist/react-native/index.js +20 -6
- package/package.json +1 -1
- package/src/react/accent-context.tsx +13 -6
- package/src/react/hooks/use-snap-colors.ts +2 -3
- package/src/react/index.tsx +101 -38
- package/src/react-native/confetti-overlay.tsx +134 -0
- package/src/react-native/index.tsx +33 -11
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
export declare function SnapPreviewAccentProvider({ pageAccent, children, }: {
|
|
2
|
+
export declare function SnapPreviewAccentProvider({ pageAccent, appearance, children, }: {
|
|
3
3
|
pageAccent: string | undefined;
|
|
4
|
+
appearance?: "light" | "dark";
|
|
4
5
|
children: ReactNode;
|
|
5
6
|
}): import("react/jsx-runtime").JSX.Element;
|
|
6
7
|
export declare function useSnapPreviewPageAccent(): string | undefined;
|
|
8
|
+
export declare function useSnapAppearance(): "light" | "dark";
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useContext } from "react";
|
|
4
|
-
const
|
|
5
|
-
export function SnapPreviewAccentProvider({ pageAccent, children, }) {
|
|
6
|
-
return (_jsx(
|
|
4
|
+
const SnapPreviewContext = createContext(null);
|
|
5
|
+
export function SnapPreviewAccentProvider({ pageAccent, appearance = "dark", children, }) {
|
|
6
|
+
return (_jsx(SnapPreviewContext.Provider, { value: { pageAccent, appearance }, children: children }));
|
|
7
7
|
}
|
|
8
8
|
export function useSnapPreviewPageAccent() {
|
|
9
|
-
return useContext(
|
|
9
|
+
return useContext(SnapPreviewContext)?.pageAccent;
|
|
10
|
+
}
|
|
11
|
+
export function useSnapAppearance() {
|
|
12
|
+
return useContext(SnapPreviewContext)?.appearance ?? "dark";
|
|
10
13
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
import { useStateStore } from "@json-render/react";
|
|
4
|
-
import { useColorMode } from "@neynar/ui/color-mode";
|
|
5
4
|
import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex.js";
|
|
6
|
-
import { useSnapPreviewPageAccent } from "../accent-context.js";
|
|
5
|
+
import { useSnapPreviewPageAccent, useSnapAppearance } from "../accent-context.js";
|
|
7
6
|
import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
|
|
8
7
|
/** Readable foreground color (black or white) for a given hex background. */
|
|
9
8
|
export function pickForegroundForBg(hex) {
|
|
@@ -69,7 +68,7 @@ function buildSnapColors(accentName, mode) {
|
|
|
69
68
|
*/
|
|
70
69
|
export function useSnapColors() {
|
|
71
70
|
const { get } = useStateStore();
|
|
72
|
-
const
|
|
71
|
+
const mode = useSnapAppearance();
|
|
73
72
|
const pageAccent = useSnapPreviewPageAccent();
|
|
74
73
|
const fromState = get("/theme/accent");
|
|
75
74
|
const accentRaw = (typeof pageAccent === "string" && pageAccent.length > 0
|
package/dist/react/index.js
CHANGED
|
@@ -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(
|
|
173
|
+
...snapPreviewPrimaryCssProperties(accentName, appearance),
|
|
121
174
|
...vars,
|
|
122
175
|
};
|
|
123
|
-
}, [
|
|
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, 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)
|
|
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
|
|
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
|
|
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.
|
|
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
|
@@ -2,28 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext, type ReactNode } from "react";
|
|
4
4
|
|
|
5
|
-
type
|
|
5
|
+
type SnapPreviewContextValue = {
|
|
6
6
|
/** From loaded snap `page.theme.accent` (undefined if the snap omits it). */
|
|
7
7
|
pageAccent: string | undefined;
|
|
8
|
+
/** Light/dark appearance passed from SnapView. */
|
|
9
|
+
appearance: "light" | "dark";
|
|
8
10
|
};
|
|
9
11
|
|
|
10
|
-
const
|
|
11
|
-
createContext<SnapPreviewAccentContextValue | null>(null);
|
|
12
|
+
const SnapPreviewContext = createContext<SnapPreviewContextValue | null>(null);
|
|
12
13
|
|
|
13
14
|
export function SnapPreviewAccentProvider({
|
|
14
15
|
pageAccent,
|
|
16
|
+
appearance = "dark",
|
|
15
17
|
children,
|
|
16
18
|
}: {
|
|
17
19
|
pageAccent: string | undefined;
|
|
20
|
+
appearance?: "light" | "dark";
|
|
18
21
|
children: ReactNode;
|
|
19
22
|
}) {
|
|
20
23
|
return (
|
|
21
|
-
<
|
|
24
|
+
<SnapPreviewContext.Provider value={{ pageAccent, appearance }}>
|
|
22
25
|
{children}
|
|
23
|
-
</
|
|
26
|
+
</SnapPreviewContext.Provider>
|
|
24
27
|
);
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export function useSnapPreviewPageAccent(): string | undefined {
|
|
28
|
-
return useContext(
|
|
31
|
+
return useContext(SnapPreviewContext)?.pageAccent;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useSnapAppearance(): "light" | "dark" {
|
|
35
|
+
return useContext(SnapPreviewContext)?.appearance ?? "dark";
|
|
29
36
|
}
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useMemo } from "react";
|
|
4
4
|
import { useStateStore } from "@json-render/react";
|
|
5
|
-
import { useColorMode } from "@neynar/ui/color-mode";
|
|
6
5
|
import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
|
|
7
|
-
import { useSnapPreviewPageAccent } from "../accent-context";
|
|
6
|
+
import { useSnapPreviewPageAccent, useSnapAppearance } from "../accent-context";
|
|
8
7
|
import type { PaletteColor } from "@farcaster/snap";
|
|
9
8
|
import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
|
|
10
9
|
|
|
@@ -113,7 +112,7 @@ function buildSnapColors(
|
|
|
113
112
|
*/
|
|
114
113
|
export function useSnapColors(): SnapColors {
|
|
115
114
|
const { get } = useStateStore();
|
|
116
|
-
const
|
|
115
|
+
const mode = useSnapAppearance();
|
|
117
116
|
const pageAccent = useSnapPreviewPageAccent();
|
|
118
117
|
const fromState = get("/theme/accent");
|
|
119
118
|
const accentRaw =
|
package/src/react/index.tsx
CHANGED
|
@@ -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(${
|
|
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
|
-
}, [
|
|
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
|
-
<
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
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 {
|
|
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(
|
|
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 =
|
|
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
|
|
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.
|
|
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}
|