Back to blog
How to Build a Native Farcaster iOS Client in SwiftUI
We’ve seen an incredible amount of innovation via Farcaster, the decentralized social protocol. Builders are creating frames (a new primitive to create in-feed social apps), bots, and alternative clients (apps). These alternative clients are often web based, but because Warpcast, the client built by the Farcaster team, was built mobile-app-first, we’ve significant interest in building native app clients. However, in all or nearly all of the examples, the mobile client is built in React Native. React Native is a powerful framework for building cross-platform mobile apps written in JavaScript, but it’s not full native. So, you know what we did.
We built a native iOS client using Apple’s Swift programming language and the SwiftUI framework.
Every year, we hack at ETHDenver. This year, we weren’t sure what we were going to build. However, at the last minute, we realize a unique spin on Farcaster client examples would be a native Swift example. So in 3 days, we hacked together a reference client. In this post, we’re going to walk through the basics of building a Farcaster client in Swift and Swift UI.
Getting Started
If you’re on Windows, sorry. This tutorial requires Xcode on a Mac. I'm sure there are hacks to get around this, so if you know them, feel free to continue. Otherwise, I’ll assume you’re on a Mac with the most recent version of Xcode installed.
Open Xcode and we’ll create a new project. Choose iOS as your target.
On the next screen, give your project a name and make sure the interface is SwiftUI and the language is Swift. We don’t need data persistence through Apple or on device outside of what we get out of the box, so leave the Host in CloudKit box at the bottom unchecked.
Next, you’ll need to choose on the local filesystem where to save the project. There will be an option to initialize a git repository. I highly recommend to check this box. It will make source control easier later.
Now, your bare bones SwiftUI project will be created with some placeholder data. At the time of this writing, SwiftUI projects automatically generate with simple state management. We’re not going to use that so we can delete the references to it. The model context that comes with the project creation is very useful for many projects and we could use it for this one, but I would like to keep the tutorial more focused.
Assuming you have this file, delete the Item.swift
file. Then update the swooop_tutorialApp.swift
file to remove the sharedModelContainer
. The file should look like this:
import SwiftUI
@main
struct swooop_tutorialApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Then update your ContentView.swift
file to look like this:
import SwiftUI
import SwiftData
struct ContentView: View {
var body: some View {
Text("Hello, world")
}
}
#Preview {
ContentView()
}
This is as bare bones as it gets, but it creates a nice starting point. Now, we’re going to need to build a server before we move forward. We’ll keep our server simple and write it in Node.js. Don’t worry, we’ll be coming back to Swift soon!
Building the Server
Now is the time to fire up your favorite non-Xcode text editor and terminal. Change into the directory where you keep your development projects, and let’s create a new project for our server. We’ll call it swooop-server
:
mkdir swooop-server && cd swooop-server
We’re going to use Bun to make our lives easier and to give us Typescript out of the box without fighting with compiler settings. If you don’t have Bun installed on your machine, follow this guide.
If and when Bun is installed, simply run the following command from your project’s folder:
bun init
This command acts similarly to npm init
, but it provides the option to create a TypeScript project and generate your index.ts
file.
Before we write our code, let’s outline what we’ll be doing via the server.
- We will allow users to sign in using Warpcast
- We will allow casts to be sent to a Hub and propagated to the Farcaster network
- We will allow fetching of casts
There’s a lot more we can do, but this is plenty to get you started. For all of this, we will use a combination of the Pinata Hub and the Pinata Farcaster API. So, you’ll need a free Pinata account if you don’t have one already. You can sign up here. Once you’ve signed up, go to the API Key tab and create a new API Key. Choose admin, give it a name, then copy the JWT.
We’re going to create some environment variables. To make sure you don’t commit these value to a remote repository, make sure you have a .gitignore
file. If you don’t, create one and add .env
in it. Now, in the root of your project, create a .env
file and add the following:
PINATA_JWT=YOUR PINATA JWT
FARCASTER_DEVELOPER_FID=THE FID FOR YOUR NEW CLIENT
FARCASTER_DEVELOPER_MNEMONIC=MNEMONIC FOR YOUR NEW CLIENT
Paste your JWT in for the value of the PINATA_JWT
environment variable. What about those other two variables, though?
Signing In With Warpcast
There is a lot that goes into signing in with Warpcast, so let’s talk about what it is and then I’ll point you to another guide I wrote that you can copy right into this project. Farcaster allows users to have multiple “signers”. Signers are very much like connections you have to web2 applications like Google and Github. Every time you use Sign In With Google, that’s an OAuth connection that lets the app take actions on your behalf using your Google account.
The same is true with Farcaster. With Farcaster, though, apps can either manage signers themselves by making smart contract calls, or they can lean on Warpcast, the most popular client, to act as an OAuth service. That’s what we’ll be doing for this app.
Here is the guide that walks you through setting up Sign in With Warpcast. It’s a bit involved, which is why we have a dedicated guide for it. Fortunately, it uses Bun and Express just like this project.
Sending Casts
Now that we have auth set up, let’s tackle the next hardest part of the server: sending casts. In order to send a cast–which is simply the data the user is posting to the Farcaster network–the message needs to be signed by the signing key generated during the auth process above. Then, it can be sent to the Hub and submitted to the network.
Before writing our endpoint, we need to add a couple of dependencies. Run the following in your terminal:
bun add @noble/hashes @farcaster/core
Then, at the top of your index.ts
file, with all the other imports, add the following:
import { hexToBytes } from "@noble/hashes/utils";
import { Message,
NobleEd25519Signer,
FarcasterNetwork,
makeCastAdd,
} from "@farcaster/core"
Now, let’s add a new POST route in our index.ts
file like this:
app.post("/message", async (req: express.Request, res: express.Response) => {
const NETWORK = FarcasterNetwork.MAINNET;
try {
const SIGNER = req?.body?.signer;
const rawFID = req?.body?.fid;
const message = req?.body?.castMessage;
const FID = parseInt(rawFID)
if(!SIGNER) {
return res.status(401).json({error: "No signer provided"});
}
if(!FID) {
return res.status(400).json({error: "No FID provided"});
}
const dataOptions = {
fid: FID,
network: NETWORK,
};
// Set up the signer
const privateKeyBytes = hexToBytes(SIGNER.slice(2));
const ed25519Signer = new NobleEd25519Signer(privateKeyBytes);
const castBody: CastBody = {
text: message,
embeds: [],
embedsDeprecated: [],
mentions: [],
mentionsPositions: [],
};
const castAddReq: any = await makeCastAdd(
castBody,
dataOptions,
ed25519Signer,
);
const castAdd: any = castAddReq._unsafeUnwrap();
const messageBytes = Buffer.from(Message.encode(castAdd).finish());
const castRequest = await fetch(
"https://hub.pinata.cloud/v1/submitMessage",
{
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: messageBytes,
},
);
const castResult = await castRequest.json();
if (!castResult.hash) {
return res.status(500).json({ error: "Failed to submit message" });
} else {
let hex = Buffer.from(castResult.hash).toString("hex");
return res.status(200).json({hex: hex});
}
} catch (error) {
console.log(error);
return res.json({ "server error": error });
}
});
This looks complicated, but it’s actually not bad. Not after you’ve climbed the mountain of Auth and came out the other side stronger. This POST
endpoint expects a request body that includes the signer (i.e. the private key string that we returned to the client when we finished the auth process), the user’s FID, and the cast text.
We parse these items into their appropriate and expected format we specify the correct Farcaster network (mainnet), and convert the private key string back into its byte format and use that to create the signer that ultimately signs the message sent to the Hub.
Next, we format the cast body and use the @farcaster/core
library to create the cast data we’ll use for submitting to the hub. The fetch request posts to the Pinata Farcaster Hub and submits the message bytes from the cast body.
Then, we wait for a response and send the cast hash as a hex string if the cast was successfully submitted.
See? That wasn’t so bad. Now, we have just one more endpoint to wire up before we return to the sweet, sweet comfort of Xcode and Swift.
Fetching Casts
All clients need a feed of some kind. This is simply data requested from a Hub. It can be filtered to match the needs of the client. In the case of Swooop, the app that inspired this post, we only fetch casts for specific channels. To keep this tutorial simple, we’re not going to do any filtering. That offers the most flexibility later for you.
Let’s write our last endpoint. It’ll be a GET endpoint called feed
. Add this to your index.ts
file:
app.get("/feed", async (req: express.Request, res: express.Response) => {
const { pageToken } = req.query;
if(!pageToken){
res.status(400).json({error: "No pageToken provided"});
}
try {
const result = await fetch(
`https://api.pinata.cloud/v3/farcaster/casts?pageLimit=200&pageToken=${nextPage}`, {
headers: {
'Authorization': `Bearer ${process.env.PINATA_JWT}`
}
}
);
const resultData = await result.json();
const casts = resultData.casts;
const simplifiedCasts = await Promise.all(
casts.map(async (cast: Cast) => {
const fname = cast.author.username;
const pfp = cast.author.pfp_url;
const { embedUrl, embedCast } = cast.embeds.reduce((acc: any, embed: Embed) => {
if (embed.url) {
acc.embedUrl.push(embed);
} else if (embed.cast_id) {
acc.embedCast.push(embed);
}
return acc;
}, { embedUrl: [], embedCast: [] });
return {
id: cast.hash,
castText: cast.text,
embedUrl: embedUrl,
embedCast: embedCast,
username: fname,
pfp: pfp,
timestamp: cast.timestamp,
likes: cast?.reactions?.likes?.length || 0,
recasts: cast?.reactions?.recasts?.length || 0
};
}),
);
res.json(simplifiedCasts)
} catch (error) {
res.status(500).json({error: error});
}
});
This endpoint takes just one optional query parameter called pageToken
. This is how the client can paginate through posts. We are using the Pinata Farcaster API to make it easier to fetch data. Direct requests to the Hub are inefficient because you have to make multiple requests to get the same data you’d get in a single request to Pinata’s API.
We are limiting our request to 200 casts, but you can adjust this to fit your needs. For fun, I decided in this tutorial, we only want top-level casts, not replies. So, we are looping through the casts returned and filtering our anything that has a parent_hash
which would indicate it is a reply. Then, we are returning the data in the format expected by the client.
There’s a lot more a client would likely do, but this is enough for us to build our simplified client. Which means we can get back to writing our Swift code.
Building the Client
Let’s revisit our Xcode setup. Remember, we have a single ContentView.swift
file. Right now, it just has text printed on the screen, but I think we should probably show casts even if you’re not logged in.
Here’s a very quickly drawn mockup of what we’re going to build:
We will need a profile button at the top and we’ll need a floating plus button at the bottom to create casts. Everything else will be taken up by the casts themselves. Let’s update our ContentView
file to look like this:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
HStack {
Spacer()
Image(systemName: "person.crop.circle.fill") // Use a default image if loading fails
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.clipShape(Circle())
.padding()
}
// Casts will go here
Spacer()
}.overlay(
GeometryReader { geometry in
Button(action: {
// Open cast form
}) {
Image(systemName: "plus") // Use a default image if loading fails
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.clipShape(Circle())
.foregroundColor(Color.black)
.padding()
}
.frame(width: geometry.size.width - 25, height: geometry.size.height - 25, alignment: .bottomTrailing)
}
)
}
}
#Preview {
ContentView()
}
With this, your preview should look like:
Let’s try to render some casts in the middle there. First, we need to create a new file called CastManager.swift
. When you create your file, choose a Swift file not a SwiftUI file. Inside the CastManager
file, add the following:
import Foundation
struct CastId: Codable {
let fid: Int
let hash: String
}
struct EmbedCast: Codable {
let castId: CastId
}
struct EmbedUrl: Codable {
let url: String
}
struct Cast: Codable {
let id: String
let castText: String
let embedUrl: [EmbedUrl]
let embedCast: [EmbedCast]
let username: String
let pfp: String
let timestamp: String
let likes: Int
let recasts: Int
}
struct PostBody: Codable {
let signer: String
let castMessage: String
let fid: String
let parentUrl: String
}
class CastManager {
static let shared = CastManager()
var casts: [Cast] = []
func fetchCasts(completion: @escaping (Result<[Cast], Error>) -> Void) {
guard let url = URL(string: "http://localhost:3000/feed?pageToken=blank") else {
completion(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: nil)))
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "No data received", code: 1, userInfo: nil)))
return
}
do {
let decodedData = try JSONDecoder().decode([Cast].self, from: data)
self.casts = decodedData
completion(.success(decodedData))
} catch {
completion(.failure(error))
}
}.resume()
}
}
This class holds an array of casts as defined by the structs above the class. It also includes a function to fetch casts. Right now, that function points to the local version of our server. When you deploy, you’d want to update that value. When the server returns casts, the response is set to the casts
value which can then be used throughout the app.
Now, let’s connect this to our ContentView
file. At the top of the ContentView
struct in that file, let’s add a state variable to hold our casts and a function to fetch those casts:
struct ContentView: View {
@State public var casts: [Cast] = []
func loadCasts() {
CastManager.shared.fetchCasts() { result in
switch result {
case .success(let casts):
self.casts = casts
case .failure(let error):
// Handle error
print("Failed to fetch casts: \(error)")
}
}
}
.....
The function loadCasts
should be called as soon as the page appears, so let’s add an onAppear
modifier right before the .overlay
we used to create our floating plus button.
.onAppear {
loadCasts()
}
.overlay(
GeometryReader { geometry in
Button(action: {
......
Now, if you run your server with bun index.ts
and run your preview in Xcode, you should see…nothing different. That’s because we still need to render the casts. Let's do that now. Find the placeholder from before that says “Casts will go here”. Replace that with:
ScrollView(.vertical) {
ForEach(casts, id: \.id) { cast in
LazyVStack(spacing: 0) {
HStack {
AsyncImageView(urlString: cast.pfp)
Text("@\(cast.username)")
Spacer()
}
Text(cast.castText)
}
.padding(.top)
}
}
Now, if we run the preview, you can see the fruits of your labor. It should look something like this:
This is looking good. You can of course style the app further, but let’s move on in the tutorial. We still have two things left to do, and we’ll need to tackle them in order. We need to connect sign in and sign out to the work we already did on the server. We also need to enable sending new casts.
When a user clicks on the avatar button in the top-right, we want to take them to an account profile or sign in page depending on the authentication state. We can make this happen by wrapping our entire ContentView
body in a NavigationStack
.
Your full ContentView
file should now look like this:
import SwiftUI
import SwiftData
struct ContentView: View {
@State public var casts: [Cast] = []
func loadCasts() {
CastManager.shared.fetchCasts() { result in
switch result {
case .success(let casts):
self.casts = casts
case .failure(let error):
// Handle error
print("Failed to fetch casts: \(error)")
}
}
}
var body: some View {
NavigationStack {
VStack {
HStack {
Spacer()
Image(systemName: "person.crop.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.clipShape(Circle())
.padding()
}
ScrollView(.vertical) {
ForEach(casts, id: \.id) { cast in
LazyVStack(spacing: 0) {
HStack {
AsyncImageView(urlString: cast.pfp)
Text("@\(cast.username)")
Spacer()
}
Text(cast.castText)
}
.padding(.top)
}
}
Spacer()
}
.padding()
.onAppear {
loadCasts()
}
.overlay(
GeometryReader { geometry in
Button(action: {
// Open cast form
}) {
Image(systemName: "plus") // Use a default image if loading fails
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.clipShape(Circle())
.foregroundColor(Color.black)
.padding()
}
.frame(width: geometry.size.width - 25, height: geometry.size.height - 25, alignment: .bottomTrailing)
}
)
}
}
}
#Preview {
ContentView()
}
Now, let’s create a new file called AccountView.swift
. Make sure this is a SwiftUI file. To keep things simple, this file will conditionally render the sign in page or the Profile page. In order to power this, we need to create a new class file to manager our user state. Create one more file, this one a Swift file, and call it UserManager.swift
. In that file, let’s create a function to fetch user data locally:
import Foundation
struct User: Codable {
let fid: Int
let signerKey: String
}
class UserManager {
static let shared = UserManager()
func getUserData() -> User {
var user_signer: String = ""
var user_fid: Int = 0
if let fid = UserDefaults.standard.value(forKey: "fid") as? String {
user_fid = Int(fid) ?? 0
} else {
return User(fid: 0, signerKey: "")
}
if let signer_key = UserDefaults.standard.value(forKey: "signer_key") as? String {
user_signer = signer_key
} else {
return User(fid: 0, signerKey: "")
}
return User(fid: user_fid, signerKey: user_signer)
}
}
Here, we create a UserManager
class and right now it just has one function. We check in the simple UserDefaults
keyvalue store for the user’s FID and their private key for signing. If either is missing, we return an empty user. Otherwise, we return both values. This is enough for us to render our AccountView
.
Update that file to look like this:
import SwiftUI
struct AccountView: View {
@State public var user: User = User(fid: 0, signerKey: "")
func loadUserData() {
let loadedUser: User = UserManager.shared.getUserData()
user = loadedUser
}
func signIn() {
}
func signOut() {
}
var body: some View {
VStack {
if user.fid == 0 {
VStack {
Text("Welcome")
.font(/*@START_MENU_TOKEN@*/.title/*@END_MENU_TOKEN@*/)
Text("Let's get signed in")
Button(action: signIn) {
Text("Sign in with Warpcast")
}
}
} else {
Text(String(UserManager.shared.getUserData().fid))
Button(action: signOut) {
Text("Sign out")
}
}
}
.onAppear {
loadUserData()
}
}
}
#Preview {
AccountView()
}
We're using a state variable to hold our logged-in user's information or lack thereof. We set that state variable by calling loadUserData
when the page appears. If the user is logged in, we display their FID and a sign-out button. if they are not, we render text and a sign in button. And finally, we have placeholders for the sign in and sign out functions. Let’s wire those up now.
To do so, we’ll need to go back to our UserManager
file. First, we need to define a struct that represents the payload we get back from the server when we start the sign in process. If you remember when we were building the server, we’ll be getting back a pollingToken, a deepLinkUrl, a publicKey, and a privateKey. So, we’ll need to define a struct at the top of our UserManager
file like this:
struct SignerPayload: Codable {
let pollingToken: String
let privateKey: String
let publicKey: String
let deepLinkUrl: String
}
Then we can add a signInWithWarpcast
function like this:
func signInWithWarpcast(completion: @escaping (Result<SignerPayload, Error>) -> Void) {
let url = URL(string: "http://localhost:3000/sign-in")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
let session = URLSession.shared
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
// Check for HTTP response status code indicating success
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response"])))
return
}
// Ensure data is present
guard let responseData = data else {
completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"])))
return
}
do {
// Decode the JSON response into a SignerPayload object
let decoder = JSONDecoder()
let signerPayload = try decoder.decode(SignerPayload.self, from: responseData)
UserDefaults.standard.set(signerPayload.privateKey, forKey: "signer_private")
UserDefaults.standard.set("false", forKey: "signer_approved")
UserDefaults.standard.set(signerPayload.pollingToken, forKey: "polling_token")
completion(.success(signerPayload))
} catch {
completion(.failure(error))
}
}
task.resume()
}
This function isn’t doing anything fancy. We’re hitting our server endpoint for sign-in (remember, in production you’ll need to update your server URL). We’re making a post request and when we get the result back, we are storing the private key as signer_key
, we’re storing an indicator as to whether the user has approved the signer, and we are storing the polling token (just in case). Finally, we return entire payload to the initiator of the function call.
Let’s go back to the AccountView
file and add this function call. In that file, find the placeholder for signIn
and add:
func signIn() {
UserManager.shared.signInWithWarpcast() { result in
switch result {
case .success(let signInResponse):
UIApplication.shared.open(URL(string: signInResponse.deepLinkUrl)!)
// We need to kick off polling here
break
case .failure(let error):
// Handle error
print("Failed to sign in: \(error)")
}
}
}
When we get a successful response back, we open the deeplinkURL so the user can approve the signer in Warpcast. But as that’s happening, we need to poll for status. Let’s add a placeholder polling function above the signIn
function in this file:
func poll(token: String) {
}
Now, back in the signIn
function, remove the comment about kicking off polling and replace it with:
poll(token: pollingToken)
Now we can go write our function that calls the polling endpoint. Open the UserManager
file again and add another struct at the top to represent the response from our polling endpoint:
struct PollingStatus: Codable {
let state: String
let userFid: Int?
}
Now, below the signInWithWarpcast
function add a new function called pollForApproval
like this:
func pollForApproval(token: String, completion: @escaping (Result<String, Error>) -> Void) {
let url = URL(string: "http://localhost:3000/sign-in/poll?pollingToken=\(token)")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
let session = URLSession.shared
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
// Check for HTTP response status code indicating success
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response"])))
return
}
// Ensure data is present
guard let responseData = data else {
completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"])))
return
}
do {
// Decode the JSON response into a SignerPayload object
let decoder = JSONDecoder()
let pollingStatus = try decoder.decode(PollingStatus.self, from: responseData)
if pollingStatus.state == "completed", pollingStatus.userFid != nil {
UserDefaults.standard.set("true", forKey: "signer_approved")
UserDefaults.standard.set(String(pollingStatus.userFid ?? 0), forKey: "fid")
}
completion(.success(pollingStatus.state))
} catch {
completion(.failure(error))
}
}
task.resume()
}
This function, like the previous one, is pretty simple. It’s a GET request to the polling endpoint. We check the state of the signer approval. If it is completed
then we update the state in our user defaults storage. We also then store the user’s FID. Regardless of the state, we return the state to the initiator of the function so that it can respond or continue polling.
Let’s connect this to our AccountView
file. Update the poll
function in that file like this:
func poll(token: String) {
UserManager.shared.pollForApproval(token: token) { result in
switch result {
case .success(let state):
if state != "completed" {
poll(token: token)
} else {
loadUserData()
}
break
case .failure(let error):
// Handle error
print("Failed to sign in: \(error)")
}
}
}
We have updated the function to call our UserManager
function and check for a UserManager
response. If we don’t get that, we recursively call the poll
function again. In a production application, it’d be smart to add a wait between calls and a max limit otherwise you could get rate limit and cause utilization problems with an endlessly running function.
If the state we get back is UserManager
we call the loadUserData
function which will automatically update state and we should see our screen update to show the FID and the sign out button.
Speaking of which, let’s complete this page by writing the signOut
function. This one is going to be dead simple:
func signOut() {
UserDefaults.standard.removeObject(forKey: "signing_key")
UserDefaults.standard.removeObject(forKey: "fid")
UserDefaults.standard.removeObject(forKey: "signer_approved")
}
Before we move on to the last step (writing messages), we need to connect the profile button to this new AccountView
file. Head back to your ContentView
file and find your avatar image. Replace it with this:
NavigationLink(destination: AccountView()) {
Image(systemName: "person.crop.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.clipShape(Circle())
.padding()
.foregroundColor(.black)
}
This will create a link that when clicked takes the user to the AccountView
page.
OK, catch your breath because we’re about to finish this thing. We need to allow for cast creation. This means creating a view that is only accessible when logged in to type up messages and send them to a Farcaster hub.
Create a new SwiftUI file called CastCreationView.swift
. This new view will have a cancel button that takes us back to the feed, a send cast button which will send and take us back to the feed, and a text input area for writing the cast message. So, add the following to that file:
import SwiftUI
struct CastCreationView: View {
@Environment(\.presentationMode) var presentationMode
@State private var inputText: String = ""
func sendCast() {
// Send the cast then return to feed
self.presentationMode.wrappedValue.dismiss()
}
var body: some View {
VStack(alignment: .leading) {
HStack {
NavigationLink(destination: ContentView()) {
Text("Cancel")
}
Spacer()
Button(action: sendCast) {
Text("Send cast")
.foregroundColor(.black)
}
}
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray, lineWidth: 1)
.padding(.horizontal)
if inputText.isEmpty {
Text("Enter your text here...")
.foregroundColor(.gray)
.padding(.horizontal, 20)
}
TextEditor(text: $inputText)
.padding()
}
.frame(height: 150)
.padding(.top)
Spacer()
}
.navigationBarBackButtonHidden(true)
.padding()
}
}
#Preview {
CastCreationView()
}
In this file, we are using the Environment property to customize navigation actions. We also have a state variable called inputText
. The view itself is made up of a cancel button which will take us back to the feed, a send cast button which will send our cast and take us back to the feed, and a text editor.
We have a placeholder for the sendCast
function, but before we fill it out, we need to write our call to the API in our CastManager
file. Open up the CastManager
file and add the following function:
func postCast(castMessage: String, completion: @escaping (Result<Data, Error>) -> Void) {
var user_fid: String = ""
var user_signer: String = ""
if let fid = UserDefaults.standard.value(forKey: "fid") as? String {
print("fid: \(fid)")
user_fid = fid
} else {
print("fid not found")
}
if let signer_private = UserDefaults.standard.value(forKey: "signer_private") as? String {
print("signer_private: \(signer_private)")
user_signer = signer_private
} else {
print("signer_private not found")
}
guard let url = URL(string: "http://localhost:3000/message") else {
completion(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: nil)))
return
}
let requestBody: [String: Any] = [
"signer": user_signer,
"castMessage": castMessage,
"fid": user_fid
]
guard let requestData = try? JSONSerialization.data(withJSONObject: requestBody) else {
completion(.failure(NSError(domain: "Failed to serialize request body", code: 0, userInfo: nil)))
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = requestData
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse else {
completion(.failure(NSError(domain: "Invalid response", code: 0, userInfo: nil)))
return
}
guard (200...299).contains(httpResponse.statusCode) else {
completion(.failure(NSError(domain: "HTTP error", code: httpResponse.statusCode, userInfo: nil)))
return
}
guard let responseData = data else {
completion(.failure(NSError(domain: "No data", code: 0, userInfo: nil)))
return
}
completion(.success(responseData))
}
task.resume()
}
This function looks worse than it is because Swift is verbose in its http request handling. All that’s happening here is the function takes castMessage
as an argument, we grab the signer_private
string and the user’s fid
from the UserDefaults
storage, and we create a request body that’s sent to the API. Finally, we return an error or a response.
Now, all we need to do is connect this to the CastCreationView
file. In that file, update the sendCast
function to look like this:
func sendCast() {
CastManager.shared.postCast(castMessage: inputText) { result in
switch result {
case .success(let result):
//
self.presentationMode.wrappedValue.dismiss()
break
case .failure(let error):
// Handle error
print("Failed to post cast: \(error)")
}
}
}
Finally, we need to connect the plus button on the feed page to the CastCreationView
file. Open up the ContentView
file and find the button that wraps the plus button. It’s inside the GeometryReader
toward the bottom. Replace the button with a NavigationLink
like this:
NavigationLink(destination: CastCreationView()) {
Image(systemName: "plus")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.clipShape(Circle())
.foregroundColor(Color.black)
.padding()
}
And with this last function, our app is done! You can conditionally render the plus button if the user is logged in using a similar method to what we used on the AccountView
page, but we’ll skip that because this tutorial is long enough.
There’s a lot more you can do to improve the UX and styling, but this is a fully functional Farcaster client build in Swift. Don’t believe me? Here it is running on my phone.
Conclusion
In this tutorial, we went from zero to a fully functioning Farcaster client written in native Swift code. It was a lot, but now, you can build anything you want natively on iOS. Farcaster is opening up new opportunities for developers and mobile apps will likely be a battleground for attention. Hopefully this tutorial gives you a leg up and can jumpstart your client development sprint.