Building Solana Mobile dApps with Expo
Summary
- Expo is an open-source collection of tools and libraries that wrap around React Native, much like Next.js is a framework built on top of React.
- In addition to simplifying the build/deploy process, Expo provides packages that give you access to mobile devices' peripherals and capabilities.
- A lot of Solana ecosystem libraries don't support React native out of the box, but you can typically use them with the right polyfills.
Lesson
So far in exploring Solana Mobile, we've used vanilla React Native to build very simple mobile dApps. Just like many web developers opt to use frameworks built on top of React, like Next.js, many React Native developers opt to use frameworks and tooling that simplify the React Native development, testing, and deployment process. The most common of these is React Native Expo.
This lesson will explore two primary topics:
- How to use React Native Expo to streamline React Native development
- How to integrate JS/TS libraries from the Solana ecosystem that don't explicitly support React Native (e.g. Metaplex)
These topics are best explored in a hands-on manner, so the majority of this lesson will be spent in the lab.
React Native Expo
Expo is an open-source collection of tools and libraries that wrap around React Native, much like Next.js is a framework built on top of React.
Expo consists of three main parts:
- Expo CLI
- The Expo Go App
- A suite of libraries that grant access to various mobile device capabilities.
The Expo CLI is a build and debugging tool that helps make all of the magic happen. Chances are, you'll only have to interact with it when you're building or starting a development server. It just works.
The Expo Go App is a really cool piece of tech that allows most apps to be developed without using an emulator or physical device. You download the app, you scan the QR from the build output and then you have a working dev environment right on your phone. Unfortunately, this will not work with the Solana mobile SDK. Coming from the Solana Expo setup article:
The traditional Expo Go development flow is only limited to certain hand-picked modules and does not support further customized native code, which Solana Mobile SDKs need. Instead, we'll need to use a custom development build which makes Solana Mobile React Native libraries (i.e Mobile Wallet Adapter) fully compatible with Expo.
Lastly, and most importantly, Expo does an amazing job providing easy-to-use libraries that give you access to the device's onboard peripherals, such as camera, battery, and speakers. The libraries are intuitive and the documentation is phenomenal.
How to create an Expo app
To get started with Expo, you first need the prerequisite setup described in the Introduction to Solana Mobile lesson. After that, you'll want to sign up for an Expo Application Services (EAS) account.
Once you have an EAS account, you can install the EAS CLI and log in:
npm install --global eas-cli
eas login
Finally, you can scaffold a new Expo app using the create-expo-app
command:
npx create-expo-app
How to build and run an Expo app
For some apps, Expo makes building really easy with the Expo Go App. The Expo Go App builds the project on a remote server and deploys to whatever emulator or device you specify.
Unfortunately, that won't work with Solana Mobile applications. Instead, you'll
need to build locally. To do that, you need an additional configuration file,
eas.json
, specifying that the project distribution is "internal." You'll need
the following inside this file:
{
"cli": {
"version": ">= 5.2.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}
With the EAS config file created, you can build using the
npx eas build --local
command plus relevant flags for any additional
requirements. For example, the following will build the project locally with a
development profile specifically for Android:
npx eas build --profile development --platform android --local
You then need to install the output APK to your device or emulator. If you're using an emulator, this is as simple as dragging the APK file onto the emulator window. If you're using a physical device, you'll have to use Android Debug Bridge (ADB):
adb install your-apk-file.apk
The installed APK is a scaffold app from Expo that facilitates a number of things, including running your app. To load your application inside of it, you need to start the development server:
npx expo start --dev-client --android
How to add Expo SDK packages to your app
The Expo SDK contains packages to simplify all kinds of things related to React Native development, from UI elements to using device peripherals. You can see all of the packages on the Expo SDK docs.
As an example, you would add
pedometer functionality
to your app by installing the expo-sensors
package:
npx expo install expo-sensors
Then you can import it in your code as you would normally expect when using JS/TS.
import { Pedometer } from "expo-sensors";
Depending on the package, there may be additional setup required. Be sure to read the docs when working with a new package.
Integrate ecosystem libraries into your Expo app
Not all React and Node libraries work with React Native out of the box. You either need to find libraries that are specifically created to work with React Native or create a workaround yourself.
When working with Solana specifically, the vast majority of ecosystem libraries do not support React Native out of the box. Fortunately, to get them to play well in a React Native environment, all we have to do is configure Expo with the correct polyfills.
Polyfills are replacement core libraries for environments that are not running Node.js. Expo does not run Node.js. Unfortunately, it can be tough to know which polyfills you need for any given application. Unless you know ahead of time, debugging polyfills means looking at the compiler errors and searching stack overflow. If it doesn't build, it's normally a polyfill problem.
Fortunately, we've compiled a list of polyfills you'll need for not only some of the standard Solana libraries but also for Metaplex.
Solana Polyfills
For a Solana + Expo app, you'll need the following:
@solana-mobile/mobile-wallet-adapter-protocol
: A React Native/Javascript API enabling interaction with MWA-compatible wallets.@solana-mobile/mobile-wallet-adapter-protocol-web3js
: A convenience wrapper to use common primitives from @solana/web3.js – such asTransaction
andUint8Array
.@solana/web3.js
: Solana Web Library for interacting with the Solana network through the JSON RPC API.react-native-get-random-values
: Secure random number generator polyfill forweb3.js
underlying Crypto library on React Native.buffer
: Buffer polyfill needed forweb3.js
on React Native.
Metaplex Polyfills
If you want to use the Metaplex SDK, you'll need to add the Metaplex library plus a few additional polyfills:
@metaplex-foundation/js@0.19.4
- Metaplex Library- Several more polyfills
assert
util
crypto-browserify
stream-browserify
readable-stream
browserify-zlib
path-browserify
react-native-url-polyfill
All of the libraries that the above polyfills are meant to replace are utilized
by the Metaplex library in the background. It's unlikely you'll be importing any
of them into your code directly. Because of this, you'll need to register the
polyfills using a metro.config.js
file. This will ensure that Metaplex uses
the polyfills instead of the usual Node.js libraries that aren't supported in
React Native. Below is an example metro.config.js
file:
const { getDefaultConfig } = require("@expo/metro-config");
const defaultConfig = getDefaultConfig(__dirname);
defaultConfig.resolver.extraNodeModules = {
crypto: require.resolve("crypto-browserify"),
stream: require.resolve("readable-stream"),
url: require.resolve("react-native-url-polyfill"),
zlib: require.resolve("browserify-zlib"),
path: require.resolve("path-browserify"),
};
module.exports = defaultConfig;
Putting it all together
As with most new tools or frameworks, initial setup can be challenging. The good news is that once you have the application compiling and running, there are very few differences in the code you write for a web vs mobile app, and there are virtually no differences when comparing the code you write for a React Native vs Expo app.
Lab
Let's practice this together by building the Mint-A-Day app, where users will able to mint a single NFT snapshot of their lives daily, creating a permanent diary of sorts.
To mint the NFTs we'll be using Metaplex's Javascript SDK along with nft.storage to store images and metadata. All of our onchain work will be on Devnet.
The first half of this lab is cobbling together the needed components to make Expo, Solana, and Metaplex all work together. We'll do this modularly so you'll know what aspects of the boilerplate align with which section.
1. Scaffold, build, and run a local Expo app
This first section will get a typescript Expo app running on an emulator. If you already have a React Native dev environment, skip step 0.
0. Set up React Native dev environment
You'll need React Native installed on your machine as well as a running emulator or physical device. You can accomplish this all with the React Native quickstart. There are also more details about this setup in the Introduction to Solana Mobile lesson
Even though we are using Expo, you'll need to follow the React Native CLI guide for initial setup.
If you are running an emulator, it is highly recommend to use a newer phone version to emulate along with providing several GB of RAM for it to run. We use 5GB of ram on our side.
1. Sign up for Expo EAS CLI
To simplify the Expo process, you'll want an Expo Application Services (EAS) account. This will help you build and run the application.
First sign up for an EAS account.
Then, install the EAS CLI and log in:
npm install --global eas-cli
eas login
2. Create the app scaffold
Let’s create our app with the following:
npx create-expo-app -t expo-template-blank-typescript solana-expo
cd solana-expo
This uses create-expo-app
to generate a new scaffold for us based on the
expo-template-blank-typescript
template. This is just an empty Typescript
React Native app.
3. Local build config
Expo defaults to building on a remote server but we need to build locally for
Solana Mobile to work correctly. We'll need to add an new config file that lets
the compiler know what we're doing. Create a file called eas.json
in the root
of your directory.
touch eas.json
Copy and paste the following into the newly created eas.json
:
{
"cli": {
"version": ">= 5.2.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}
4. Build and emulate
Now let's build the project. You will choose y
for every answer. This will
take a while to complete.
npx eas build --profile development --platform android --local
When the command is done, you will get an output file at the root of your
directory. This file will have a naming format of build-XXXXXXXXXXX.apk
.
Locate this file in your file explorer and drag it into your emulator. The
emulator should show a message that it is installing the new APK. When it
finishes installing, you should see the APK as an app icon in the emulator.
The app that was installed is just a scaffold app from Expo. The last thing you'll need to do is run the following command to run the development server:
npx expo start --dev-client --android
This should open and run the app in your Android emulator.
NOTE Every time you add in new dependencies, you'll have to build and re-install the app. Anything visual or logic-based should be captured by the hot-reloader.
2. Configure your Expo app to work with Solana
Now that we have an Expo app up and running, we need to add our Solana dependencies, including installing a wallet we can use in the emulator. If you already have a Devnet-enabled wallet installed you can skip step 0.
0. Install a Devnet-enabled Solana wallet
You'll need a wallet that supports Devnet to test with. In our Mobile Wallet Adapter lesson we created one of these. Let's install it from the solution branch in a different directory from our app:
cd ..
git clone https://github.com/Unboxed-Software/react-native-fake-solana-wallet
cd react-native-fake-solana-wallet
git checkout solution
npm run install
The wallet should be installed on your emulator or device. Make sure to open the newly installed wallet and airdrop yourself some SOL.
Make sure to return to the wallet directory as we'll be working there the rest of the lab.
cd ..
cd solana-expo
1. Install Solana dependencies
We will install some basic Solana dependencies that are likely to be needed by all Solana mobile apps. This will include some polyfills that allow otherwise incompatible packages to work with React native:
npm install \
@solana/web3.js \
@solana-mobile/mobile-wallet-adapter-protocol-web3js \
@solana-mobile/mobile-wallet-adapter-protocol \
react-native-get-random-values \
buffer
3. Add Solana boilerplate providers
Next, let's add some Solana boilerplate that can springboard you into most Solana-based apps.
Create two new folders: components
and screens
.
We are going to use some boilerplate code from the
first Mobile lesson. We will be
copying over components/AuthProvider.tsx
and
components/ConnectionProvider.tsx
. These files provide us with a Connection
object as well as some helper functions that authorize our dapp.
Create file components/AuthProvider.tsx
and copy the contents
of our existing Auth Provider from Github
into the new file.
Secondly, create file components/ConnectionProvider.tsx
and copy the contents
of our existing Connection Provider from Github
into the new file.
Now let's create a boilerplate for our main screen in screens/MainScreen.tsx
:
import { View, Text } from "react-native";
import React from "react";
export function MainScreen() {
return (
<View>
<Text>Solana Expo App</Text>
</View>
);
}
Finally, let's change App.tsx
to wrap our application in the two providers we
just created:
import "react-native-get-random-values";
import { StatusBar } from "expo-status-bar";
import { StyleSheet, Text, View } from "react-native";
import { ConnectionProvider } from "./components/ConnectionProvider";
import { AuthorizationProvider } from "./components/AuthProvider";
import { clusterApiUrl } from "@solana/web3.js";
import { MainScreen } from "./screens/MainScreen";
global.Buffer = require("buffer").Buffer;
export default function App() {
const cluster = "devnet";
const endpoint = clusterApiUrl(cluster);
return (
<ConnectionProvider
endpoint={endpoint}
cluster={cluster}
config={{ commitment: "processed" }}
>
<AuthorizationProvider cluster={cluster}>
<MainScreen />
</AuthorizationProvider>
</ConnectionProvider>
);
}
Notice we've added two polyfills above: buffer
and
react-native-get-random-values
. These are necessary for the Solana
dependencies to run correctly.
4. Build and run Solana boilerplate
Let's make sure everything is working and compiling correctly. In Expo, anytime you change the dependencies, you'll need to rebuild and re-install the app.
Optional: To avoid possible build version conflicts, you may want to uninstall the previous version before you drag and drop the new one in.
Build:
npx eas build --profile development --platform android --local
Install: Drag the resulting build file into your emulator.
Run:
npx expo start --dev-client --android
Everything should compile and you should have a boilerplate Solana Expo app.
3. Configure your Expo app to work with Metaplex
Metaplex is your one-stop-shop for all of your NFT API needs. However, it requires a little more setup. The good news is if you ever want to fetch, mint or edit NFTs in your future apps, you'll have another boilerplate to here that you can reference.
1. Install Metaplex dependencies
The Metaplex SDK abstracts away a lot of the minutia of working with NFTs, however it was written largely for Node.js, so we'll need several more polyfills to make it work:
npm install assert \
util \
crypto-browserify \
stream-browserify \
readable-stream \
browserify-zlib \
path-browserify \
react-native-url-polyfill \
@metaplex-foundation/js@0.19.4
2. Polyfill config
We won't be importing any of the above polyfills in our code directly, so we
need to add them to a metro.config.js
file to ensure that Metaplex uses them:
touch metro.config.js
Copy and paste the following into metro.config.js
:
// Import the default Expo Metro config
const { getDefaultConfig } = require("@expo/metro-config");
// Get the default Expo Metro configuration
const defaultConfig = getDefaultConfig(__dirname);
// Customize the configuration to include your extra node modules
defaultConfig.resolver.extraNodeModules = {
crypto: require.resolve("crypto-browserify"),
stream: require.resolve("readable-stream"),
url: require.resolve("react-native-url-polyfill"),
zlib: require.resolve("browserify-zlib"),
path: require.resolve("path-browserify"),
};
// Export the modified configuration
module.exports = defaultConfig;
3. Metaplex provider
We're going to create a Metaplex provider file that will help us access a
Metaplex
object. This Metaplex
object is what gives us access to all of the
functions we'll need like fetch
and create
. To do this we create a new file
/components/MetaplexProvider.tsx
. Here we pipe our mobile wallet adapter into
an IdentitySigner
for the Metaplex
object to use. This allows it to call
several privileged functions on our behalf:
import {
IdentitySigner,
Metaplex,
MetaplexPlugin,
} from "@metaplex-foundation/js";
import {
transact,
Web3MobileWallet,
} from "@solana-mobile/mobile-wallet-adapter-protocol-web3js";
import { Connection, Transaction } from "@solana/web3.js";
import { useMemo } from "react";
import { Account } from "./AuthProvider";
export const mobileWalletAdapterIdentity = (
mwaIdentitySigner: IdentitySigner,
): MetaplexPlugin => ({
install(metaplex: Metaplex) {
metaplex.identity().setDriver(mwaIdentitySigner);
},
});
export const useMetaplex = (
connection: Connection,
selectedAccount: Account | null,
authorizeSession: (wallet: Web3MobileWallet) => Promise<Account>,
) => {
return useMemo(() => {
if (!selectedAccount || !authorizeSession) {
return { mwaIdentitySigner: null, metaplex: null };
}
const mwaIdentitySigner: IdentitySigner = {
publicKey: selectedAccount.publicKey,
signMessage: async (message: Uint8Array): Promise<Uint8Array> => {
return await transact(async (wallet: Web3MobileWallet) => {
await authorizeSession(wallet);
const signedMessages = await wallet.signMessages({
addresses: [selectedAccount.publicKey.toBase58()],
payloads: [message],
});
return signedMessages[0];
});
},
signTransaction: async (
transaction: Transaction,
): Promise<Transaction> => {
return await transact(async (wallet: Web3MobileWallet) => {
await authorizeSession(wallet);
const signedTransactions = await wallet.signTransactions({
transactions: [transaction],
});
return signedTransactions[0];
});
},
signAllTransactions: async (
transactions: Transaction[],
): Promise<Transaction[]> => {
return transact(async (wallet: Web3MobileWallet) => {
await authorizeSession(wallet);
const signedTransactions = await wallet.signTransactions({
transactions: transactions,
});
return signedTransactions;
});
},
};
const metaplex = Metaplex.make(connection).use(
mobileWalletAdapterIdentity(mwaIdentitySigner),
);
return { metaplex };
}, [authorizeSession, selectedAccount, connection]);
};
4. NFT Provider
We're also making a higher-level NFT provider that helps with NFT state
management. It combines all three of our previous providers:
ConnectionProvider
, AuthProvider
, and MetaplexProvider
to allow us to
create our Metaplex
object. We will fill this out at a later step; for now, it
makes for a good boilerplate.
Let's create the new file components/NFTProvider.tsx
:
import "react-native-url-polyfill/auto";
import { useConnection } from "./ConnectionProvider";
import { Account, useAuthorization } from "./AuthProvider";
import React, { ReactNode, createContext, useContext, useState } from "react";
import { useMetaplex } from "./MetaplexProvider";
export interface NFTProviderProps {
children: ReactNode;
}
export interface NFTContextState {}
const DEFAULT_NFT_CONTEXT_STATE: NFTContextState = {};
const NFTContext = createContext<NFTContextState>(DEFAULT_NFT_CONTEXT_STATE);
export function NFTProvider(props: NFTProviderProps) {
const { children } = props;
const { connection } = useConnection();
const { authorizeSession } = useAuthorization();
const [account, setAccount] = useState<Account | null>(null);
const { metaplex } = useMetaplex(connection, account, authorizeSession);
const state = {};
return <NFTContext.Provider value={state}>{children}</NFTContext.Provider>;
}
export const useNFT = (): NFTContextState => useContext(NFTContext);
Notice we've added yet another polyfill to the top
import "react-native-url-polyfill/auto";
5. Wrap provider
Now, let's wrap our new NFTProvider
around MainScreen
in App.tsx
:
import "react-native-get-random-values";
import { ConnectionProvider } from "./components/ConnectionProvider";
import { AuthorizationProvider } from "./components/AuthProvider";
import { clusterApiUrl } from "@solana/web3.js";
import { MainScreen } from "./screens/MainScreen";
import { NFTProvider } from "./components/NFTProvider";
global.Buffer = require("buffer").Buffer;
export default function App() {
const cluster = "devnet";
const endpoint = clusterApiUrl(cluster);
return (
<ConnectionProvider
endpoint={endpoint}
cluster={cluster}
config={{ commitment: "processed" }}
>
<AuthorizationProvider cluster={cluster}>
<NFTProvider>
<MainScreen />
</NFTProvider>
</AuthorizationProvider>
</ConnectionProvider>
);
}
6. Build and run
Lastly, let's build and re-install the app to make sure things are still working.
Build:
npx eas build --profile development --platform android --local
Install:
Drag the resulting build file into your emulator.
Run:
npx expo start --dev-client --android
4. Configure your Expo app to take and upload photos
Everything we've done to this point is effectively boilerplate. We need to add the functionality we intend for our Mint-A-Day app to have. Mint-A-day is a daily snapshot app. It lets users take a snapshot of their life daily in the form of minting an NFT.
The app will need access to the device's camera and a place to remotely store the captured images. Fortunately, Expo SDK can provide access to the camera and NFT.Storage can store your NFT files for free.
1. Camera setup
Let's start by setting up the Expo-specific dependency we'll be using:
expo-image-picker
. This lets us use the device's camera to take pictures that
we'll subsequently turn into NFTs. We're specifically using the image picker
rather than the camera since emulators don't have cameras. This package will
simulate a camera for us in the emulator. Install it with the following command:
npx expo install expo-image-picker
In addition to installation, the expo-image-picker
package needs to be added
as a plugin in app.json
:
"expo": {
// ....
"plugins": [
[
"expo-image-picker",
{
"photosPermission": "Allows you to use images to create solana NFTs"
}
]
],
// ....
}
This particular dependency makes it super simple to use the camera. To allow the user to take a picture and return the image all you have to do is call the following:
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 1,
});
No need to add this anywhere yet - we'll get to it in a few steps.
2. NFT.Storage setup
The last thing we need to do is set up our access to nft.storage. We'll need to get an API key and add it as an environment variable, then we need to add one last dependency to convert our images into a file type we can upload.
We'll be using NFT.storage to host our NFTs with IPFS since they do this for free. Sign up, and create an API key. Keep this API key private.
Best practices suggest keeping API keys in a .env
file with .env
added to
your .gitignore
. It's also a good idea to create a .env.example
file that
can be committed to your repo and shows what environment variables are needed
for the project.
Create both files, in the root of your directory and add .env
to your
.gitignore
file.
Then, add your API key to the .env
file with the name
EXPO_PUBLIC_NFT_STORAGE_API
. Now you'll be able to access your API key safely
in the application.
Lastly, install rn-fetch-blob
. This package will help us grab images from the
device's URI scheme and turn them into Blobs we can the upload to
NFT.storage.
Install it with the following:
npm i rn-fetch-blob
3. Final build
Build and reinstall if you want to make sure it's all working. This is the last time we'll have to do this for this lab. Everything else should be hot-loadable.
Build:
npx eas build --profile development --platform android --local
Install:
Drag the resulting build file into your emulator.
Run:
npx expo start --dev-client --android
5. Add functionality to complete your Expo app
We're through the setup! Let's create the actual functionality for our Mint-A-Day app. Fortunately, there are only two files we have to focus on now:
NFTProvider.tsx
will largely manage our app state and NFT data.MainScreen.tsx
will capture input and show our NFTs
The app itself is relatively straightforward. The general flow is:
- The user connects (authorizes) using the
transact
function and by callingauthorizeSession
inside the callback - Our code then uses the
Metaplex
object to fetch all of the NFTs created by the user - If an NFT has not been created for the current day, allow the user to take a picture, upload it, and mint it as an NFT
1. NFT Provider
NFTProvider.tsx
will control the state with our custom NFTProviderContext
.
This should have the following fields:
metaplex: Metaplex | null
- Holds the metaplex object that we use to callfetch
andcreate
publicKey: PublicKey | null
- The NFT creator's public keyisLoading: boolean
- Manages loading stateloadedNFTs: (Nft | Sft | SftWithToken | NftWithToken)[] | null
- An array of the user's snapshot NFTsnftOfTheDay: (Nft | Sft | SftWithToken | NftWithToken) | null
- A reference to the NFT created todayconnect: () => void
- A function for connecting to the Devnet-enabled walletfetchNFTs: () => void
- A function that fetches the user's snapshot NFTscreateNFT: (name: string, description: string, fileUri: string) => void
- A function that creates a new snapshot NFT
export interface NFTContextState {
metaplex: Metaplex | null; // Holds the metaplex object that we use to call `fetch` and `create` on.
publicKey: PublicKey | null; // The public key of the authorized wallet
isLoading: boolean; // Loading state
loadedNFTs: (Nft | Sft | SftWithToken | NftWithToken)[] | null; // Array of loaded NFTs that contain metadata
nftOfTheDay: (Nft | Sft | SftWithToken | NftWithToken) | null; // The NFT snapshot created on the current day
connect: () => void; // Connects (and authorizes) us to the Devnet-enabled wallet
fetchNFTs: () => void; // Fetches the NFTs using the `metaplex` object
createNFT: (name: string, description: string, fileUri: string) => void; // Creates the NFT
}
The state flow here is: connect
, fetchNFTs
, and then createNFT
. We'll walk
through the code for each of them and then show you the entire file at the end:
-
connect
- This function will connect and authorize the app, and then store the resultingpublicKey
into the state.const connect = () => {
if (isLoading) return;
setIsLoading(true);
transact(async wallet => {
const auth = await authorizeSession(wallet);
setAccount(auth);
}).finally(() => {
setIsLoading(false);
});
}; -
fetchNFTs
- This function will fetch the NFTs using Metaplex:const fetchNFTs = async () => {
if (!metaplex || !account || isLoading) return;
setIsLoading(true);
try {
const nfts = await metaplex.nfts().findAllByCreator({
creator: account.publicKey,
});
const loadedNFTs = await Promise.all(
nfts.map(nft => {
return metaplex.nfts().load({ metadata: nft as Metadata });
}),
);
setLoadedNFTs(loadedNFTs);
// Check if we already took a snapshot today
const nftOfTheDayIndex = loadedNFTs.findIndex(nft => {
return formatDate(new Date(Date.now())) === nft.name;
});
if (nftOfTheDayIndex !== -1) {
setNftOfTheDay(loadedNFTs[nftOfTheDayIndex]);
}
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
}; -
createNFT
- This function will upload a file to NFT.Storage, and then use Metaplex to create and mint an NFT to your wallet. This comes in three parts, uploading the image, uploading the metadata and then minting the NFT.To upload to NFT.Storage you just make a POST with your API key and the image/metadata as the body.
We'll create two helper functions for uploading the image and metadata separately, then tie them together into a single
createNFT
function:// https://nft.storage/api-docs/
const uploadImage = async (fileUri: string): Promise<string> => {
const imageBytesInBase64: string = await RNFetchBlob.fs.readFile(
fileUri,
"base64",
);
const bytes = Buffer.from(imageBytesInBase64, "base64");
const response = await fetch("https://api.nft.storage/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EXPO_PUBLIC_NFT_STORAGE_API}`,
"Content-Type": "image/jpg",
},
body: bytes,
});
const data = await response.json();
const cid = data.value.cid;
return cid as string;
};
const uploadMetadata = async (
name: string,
description: string,
imageCID: string,
): Promise<string> => {
const response = await fetch("https://api.nft.storage/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EXPO_PUBLIC_NFT_STORAGE_API}`,
},
body: JSON.stringify({
name,
description,
image: `https://ipfs.io/ipfs/${imageCID}`,
}),
});
const data = await response.json();
const cid = data.value.cid;
return cid;
};Minting the NFT after the image and metadata have been uploaded is as simple as calling
metaplex.nfts().create(...)
. Below shows thecreateNFT
function tying everything together:const createNFT = async (
name: string,
description: string,
fileUri: string,
) => {
if (!metaplex || !account || isLoading) return;
setIsLoading(true);
try {
const imageCID = await uploadImage(fileUri);
const metadataCID = await uploadMetadata(name, description, imageCID);
const nft = await metaplex.nfts().create({
uri: `https://ipfs.io/ipfs/${metadataCID}`,
name: name,
sellerFeeBasisPoints: 0,
});
setNftOfTheDay(nft.nft);
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
We'll put all of the above into the NFTProvider.tsx
file. All together, this
looks as follows:
import "react-native-url-polyfill/auto";
import React, { ReactNode, createContext, useContext, useState } from "react";
import {
Metaplex,
PublicKey,
Metadata,
Nft,
Sft,
SftWithToken,
NftWithToken,
} from "@metaplex-foundation/js";
import { useConnection } from "./ConnectionProvider";
import { Connection, clusterApiUrl } from "@solana/web3.js";
import { transact } from "@solana-mobile/mobile-wallet-adapter-protocol";
import { Account, useAuthorization } from "./AuthProvider";
import RNFetchBlob from "rn-fetch-blob";
import { useMetaplex } from "./MetaplexProvider";
export interface NFTProviderProps {
children: ReactNode;
}
export interface NFTContextState {
metaplex: Metaplex | null;
publicKey: PublicKey | null;
isLoading: boolean;
loadedNFTs: (Nft | Sft | SftWithToken | NftWithToken)[] | null;
nftOfTheDay: (Nft | Sft | SftWithToken | NftWithToken) | null;
connect: () => void;
fetchNFTs: () => void;
createNFT: (name: string, description: string, fileUri: string) => void;
}
const DEFAULT_NFT_CONTEXT_STATE: NFTContextState = {
metaplex: new Metaplex(new Connection(clusterApiUrl("devnet"))),
publicKey: null,
isLoading: false,
loadedNFTs: null,
nftOfTheDay: null,
connect: () => PublicKey.default,
fetchNFTs: () => {},
createNFT: (name: string, description: string, fileUri: string) => {},
};
const NFTContext = createContext<NFTContextState>(DEFAULT_NFT_CONTEXT_STATE);
export function formatDate(date: Date) {
return `${date.getDate()}.${date.getMonth()}.${date.getFullYear()}`;
}
export function NFTProvider(props: NFTProviderProps) {
const { children } = props;
const { connection } = useConnection();
const { authorizeSession } = useAuthorization();
const [account, setAccount] = useState<Account | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [nftOfTheDay, setNftOfTheDay] = useState<
(Nft | Sft | SftWithToken | NftWithToken) | null
>(null);
const [loadedNFTs, setLoadedNFTs] = useState<
(Nft | Sft | SftWithToken | NftWithToken)[] | null
>(null);
const { metaplex } = useMetaplex(connection, account, authorizeSession);
const connect = () => {
if (isLoading) return;
setIsLoading(true);
transact(async wallet => {
const auth = await authorizeSession(wallet);
setAccount(auth);
}).finally(() => {
setIsLoading(false);
});
};
const fetchNFTs = async () => {
if (!metaplex || !account || isLoading) return;
setIsLoading(true);
try {
const nfts = await metaplex.nfts().findAllByCreator({
creator: account.publicKey,
});
const loadedNFTs = await Promise.all(
nfts.map(nft => {
return metaplex.nfts().load({ metadata: nft as Metadata });
}),
);
setLoadedNFTs(loadedNFTs);
// Check if we already took a snapshot today
const nftOfTheDayIndex = loadedNFTs.findIndex(nft => {
return formatDate(new Date(Date.now())) === nft.name;
});
if (nftOfTheDayIndex !== -1) {
setNftOfTheDay(loadedNFTs[nftOfTheDayIndex]);
}
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
// https://nft.storage/api-docs/
const uploadImage = async (fileUri: string): Promise<string> => {
const imageBytesInBase64: string = await RNFetchBlob.fs.readFile(
fileUri,
"base64",
);
const bytes = Buffer.from(imageBytesInBase64, "base64");
const response = await fetch("https://api.nft.storage/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EXPO_PUBLIC_NFT_STORAGE_API}`,
"Content-Type": "image/jpg",
},
body: bytes,
});
const data = await response.json();
const cid = data.value.cid;
return cid as string;
};
const uploadMetadata = async (
name: string,
description: string,
imageCID: string,
): Promise<string> => {
const response = await fetch("https://api.nft.storage/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EXPO_PUBLIC_NFT_STORAGE_API}`,
},
body: JSON.stringify({
name,
description,
image: `https://ipfs.io/ipfs/${imageCID}`,
}),
});
const data = await response.json();
const cid = data.value.cid;
return cid;
};
const createNFT = async (
name: string,
description: string,
fileUri: string,
) => {
if (!metaplex || !account || isLoading) return;
setIsLoading(true);
try {
const imageCID = await uploadImage(fileUri);
const metadataCID = await uploadMetadata(name, description, imageCID);
const nft = await metaplex.nfts().create({
uri: `https://ipfs.io/ipfs/${metadataCID}`,
name: name,
sellerFeeBasisPoints: 0,
});
setNftOfTheDay(nft.nft);
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
const publicKey = account?.publicKey ?? null;
const state = {
isLoading,
account,
publicKey,
metaplex,
nftOfTheDay,
loadedNFTs,
connect,
fetchNFTs,
createNFT,
};
return <NFTContext.Provider value={state}>{children}</NFTContext.Provider>;
}
export const useNFT = (): NFTContextState => useContext(NFTContext);
2. Main Screen
Our main screen will consist of three parts: The image of the day, our action button, and the carousel of previous snapshots.
The image of the day is displayed on the top half of the app, the action button right under it, and the carousel under that.
The action button follows the state of our NFTProvider
: first connect
, then
fetchNFTs
, and finally mintNFT
. Of these, we only need to do some extra work
for mintNFT
.
The mintNFT
function uses the Expo library to open up the camera with
ImagePicker.launchCameraAsync
. When an image is taken, it's local path is
returned. The last thing we need to do is specify when the image was taken. Then
we'll make the name of the NFT the date in MM.DD.YY
format and store the unix
timestamp as the description. Finally, we pass the image path, name and
description to our createNFT
function from NFTProvider
to mint the NFT.
const mintNFT = async () => {
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 1,
});
if (!result.canceled) {
setCurrentImage({
uri: result.assets[0].uri,
date: todaysDate,
});
createNFT(
formatDate(todaysDate),
`${todaysDate.getTime()}`,
result.assets[0].uri,
);
}
};
The full code for MainScreen.tsx
is as follows:
import {
View,
Button,
Image,
StyleSheet,
ScrollView,
Text,
} from "react-native";
import React, { useEffect } from "react";
import { formatDate, useNFT } from "../components/NFTProvider";
import * as ImagePicker from "expo-image-picker";
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#292524",
},
titleText: {
color: "white",
},
topSection: {
flex: 1,
justifyContent: "center",
alignItems: "center",
textAlign: "center",
paddingTop: 30,
},
imageOfDay: {
width: "80%",
height: "80%",
resizeMode: "cover",
margin: 10,
},
bottomSection: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
carousel: {
justifyContent: "center",
alignItems: "center",
},
carouselText: {
textAlign: "center",
color: "white",
},
carouselImage: {
width: 100,
height: 100,
margin: 5,
resizeMode: "cover",
},
});
export interface NFTSnapshot {
uri: string;
date: Date;
}
// Placeholder image URL or local source
const PLACEHOLDER: NFTSnapshot = {
uri: "https://placehold.co/400x400/png",
date: new Date(Date.now()),
};
const DEFAULT_IMAGES: NFTSnapshot[] = new Array(7).fill(PLACEHOLDER);
export function MainScreen() {
const {
fetchNFTs,
connect,
publicKey,
isLoading,
createNFT,
loadedNFTs,
nftOfTheDay,
} = useNFT();
const [currentImage, setCurrentImage] =
React.useState<NFTSnapshot>(PLACEHOLDER);
const [previousImages, setPreviousImages] =
React.useState<NFTSnapshot[]>(DEFAULT_IMAGES);
const todaysDate = new Date(Date.now());
useEffect(() => {
if (!loadedNFTs) return;
const loadedSnapshots = loadedNFTs.map(loadedNft => {
if (!loadedNft.json) return null;
if (!loadedNft.json.name) return null;
if (!loadedNft.json.description) return null;
if (!loadedNft.json.image) return null;
const uri = loadedNft.json.image;
const unixTime = Number(loadedNft.json.description);
if (!uri) return null;
if (isNaN(unixTime)) return null;
return {
uri: loadedNft.json.image,
date: new Date(unixTime),
} as NFTSnapshot;
});
// Filter out null values
const cleanedSnapshots = loadedSnapshots.filter(loadedSnapshot => {
return loadedSnapshot !== null;
}) as NFTSnapshot[];
// Sort by date
cleanedSnapshots.sort((a, b) => {
return b.date.getTime() - a.date.getTime();
});
setPreviousImages(cleanedSnapshots as NFTSnapshot[]);
}, [loadedNFTs]);
useEffect(() => {
if (!nftOfTheDay) return;
setCurrentImage({
uri: nftOfTheDay.json?.image ?? "",
date: todaysDate,
});
}, [nftOfTheDay]);
const mintNFT = async () => {
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 1,
});
if (!result.canceled) {
setCurrentImage({
uri: result.assets[0].uri,
date: todaysDate,
});
createNFT(
formatDate(todaysDate),
`${todaysDate.getTime()}`,
result.assets[0].uri,
);
}
};
const handleNFTButton = async () => {
if (!publicKey) {
connect();
} else if (loadedNFTs === null) {
fetchNFTs();
} else if (!nftOfTheDay) {
mintNFT();
} else {
alert("All done for the day!");
}
};
const renderNFTButton = () => {
let buttonText = "";
if (!publicKey) buttonText = "Connect Wallet";
else if (loadedNFTs === null) buttonText = "Fetch NFTs";
else if (!nftOfTheDay) buttonText = "Create Snapshot";
else buttonText = "All Done!";
if (isLoading) buttonText = "Loading...";
return <Button title={buttonText} onPress={handleNFTButton} />;
};
const renderPreviousSnapshot = (snapshot: NFTSnapshot, index: number) => {
const date = snapshot.date;
const formattedDate = formatDate(date);
return (
<View key={index}>
<Image source={snapshot} style={styles.carouselImage} />
<Text style={styles.carouselText}>{formattedDate}</Text>
</View>
);
};
return (
<View style={styles.container}>
{/* Top Half */}
<View style={styles.topSection}>
<Text style={styles.titleText}>Mint-A-Day</Text>
<Image source={currentImage} style={styles.imageOfDay} />
{renderNFTButton()}
</View>
{/* Bottom Half */}
<View style={styles.bottomSection}>
<ScrollView horizontal contentContainerStyle={styles.carousel}>
{previousImages.map(renderPreviousSnapshot)}
</ScrollView>
</View>
</View>
);
}
3. Test
Now it's time to create our first snapshot! First, open up your Devnet-enabled
wallet and make sure you have some SOL. Next, tap on Connect Wallet
and
approve the app. Fetch all of the NFTs by tapping Fetch NFTs
. Lastly, tap
Create Snapshot
to upload and mint.
Congratulations! That was not an easy or quick lab. You're doing great if you've
made it this far. If you run into any issues, please go back through the lab
and/or reference the final solution code on the
main
branch in Github.
Challenge
Now it's your turn. Create your own Expo application from scratch. You're welcome to choose your own, or you can select from the following ideas:
- Instead of a daily image snapshot, create an application that lets users write a journal entry for the day, then mint it as an NFT
- Create a basic NFT viewer app to see all your wonderful JPEGs
- Make a simplified clone of Stepn using the pedometer
from
expo-sensors
Push your code to GitHub and tell us what you thought of this lesson!