Pinata
Published in

Pinata

How To Build A Video App on Farcaster

Using IPFS, Pinata, and FarcasterJS

Getting Started

Creating The Front End

npx create-next-app farcaster-vine
npm i @pinata/sdk @standard-crypto/farcaster-js @ethersproject/providers @ethersproject/wallet wagmi axios formidable
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
@tailwind base;
@tailwind components;
@tailwind utilities;
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { WagmiConfig, createClient, configureChains, goerli } from 'wagmi'
import { alchemyProvider } from 'wagmi/providers/alchemy'
import { publicProvider } from 'wagmi/providers/public'
import { CoinbaseWalletConnector } from 'wagmi/connectors/coinbaseWallet'
import { InjectedConnector } from 'wagmi/connectors/injected'
import { MetaMaskConnector } from 'wagmi/connectors/metaMask'
import { WalletConnectConnector } from 'wagmi/connectors/walletConnect'

const { chains, provider, webSocketProvider } = configureChains(
[goerli],
[
alchemyProvider({ apiKey: 'YOUR_ALCHEMY_GOERLI_KEY' }),
publicProvider(),
]
)

const client = createClient({
autoConnect: true,
connectors: [
new MetaMaskConnector({ chains }),
new CoinbaseWalletConnector({
chains,
options: {
appName: 'wagmi',
},
}),
new WalletConnectConnector({
chains,
options: {
qrcode: true,
},
}),
new InjectedConnector({
chains,
options: {
name: 'Injected',
shimDisconnect: true,
},
}),
],
provider,
webSocketProvider,
})

export default function App({ Component, pageProps }: AppProps) {
return (
<WagmiConfig client={client}>
<Component {...pageProps} />
</WagmiConfig>
)
}
import Feed from '../components/Feed'
import Header from '../components/Header'
import axios from 'axios';

export default function Home() {
const loadPosts = async () => {

}
return (
<div className="w-full">
<Header loadPosts={loadPosts} />
<Feed />
</div>
)
}
import React from 'react'

const Feed = () => {

return (
<div className="mt-20">
Feed
</div>
)
}

export default Feed
import React, { useState, useEffect } from 'react'
import { fetchProfile, generateAuthToken, getAuthTokenFromFC } from '../utils/farcaster';
import { useSignMessage, useAccount } from 'wagmi';
import { utils } from "ethers";
import { getMediaRecorder } from '../utils/mediaRecorder';
import AuthModal from './AuthModal';
import axios from 'axios';

const mimeType = 'video/webm'

type props = {
loadPosts: () => void;
}

const Header = (props:props) => {
const { loadPosts } = props;
const [caption, setCaption] = useState("");
const [recording, setRecording] = useState<boolean>(false);
const [recordView, setRecordView] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string>("");
const [showAuthModal, setShowAuthModal] = useState<boolean>(false);
const [user, setUser] = useState<any>(null);
const [strm, setStream] = useState<any>(null);
const [amount, setAmountOfCameras] = useState<number>(0);
const [mediaRecorder, setMediaRecorder] = useState<any>(null)
const [time, setTime] = useState(6)
const [blobData, setBlob] = useState<any>(null)
const [posting, setPosting] = useState(false)

const { address, connector, isConnected } = useAccount();
const { data, error, isLoading, signMessage } = useSignMessage({
async onSuccess(data, variables:any) {
const sig = Buffer.from(utils.arrayify(data)).toString("base64");
const auth = await getAuthTokenFromFC(`Bearer eip191:${sig}`, variables.message)
localStorage.setItem("fc-token", JSON.stringify(auth));
getUser()
},
});

useEffect(() => {
if(recordView) {
getMediaRecorder(previewUrl, setAmountOfCameras, strm, setStream);
}
}, [recordView]);

const recordVideo = async () => {
// Check if user is signed in and has a Farcaster account
// If so, allow recording. If not, post alert
const user = await checkAuth();
const profile = await fetchProfile();
if(!user || !profile) {
isConnected && address ? getNewAuthToken() : setShowAuthModal(true);
} else {
setUser(profile);
setRecordView(true);
}
}

const checkAuth = async () => {
let user = JSON.parse(localStorage.getItem("fc-profile") || "");
return user;
};

const getNewAuthToken = async () => {
const payload = generateAuthToken();
signMessage({message: payload})
}

const getUser = async () => {
try {
const user = await fetchProfile();
if(user) {
localStorage.setItem("fc-profile", JSON.stringify(user));
setUser(user);
setRecordView(true);
} else {
alert("User not found");
return null;
}
} catch (error) {
console.log(error);
alert("User not found");
return null;
}
}

const handleRecord = async () => {

}

const cancel = () => {
setStream(null);
setRecordView(false);
setRecording(false);
setPreviewUrl("");
}

const handleUpload = async () => {

}

return (
<div className="w-full">
<div className="flex flex-row justify-between row between w-full">
<p>Pinnie's Video Feed</p>
<button onClick={recordVideo}>Record New Video</button>
</div>
{
recordView &&
<div className="fixed top-20 h-full w-full m-auto bg-white">
<video
className={!previewUrl ? 'hidden' : 'm-auto w-3/4'}
id="preview"
autoPlay
loop
controls
playsInline
src=""
muted={true}
/>{' '}
<video
className={previewUrl ? 'hidden' : 'm-auto w-3/4'}
autoPlay
playsInline
id="video"
src=""
muted={true}
/>
{
previewUrl ?
<div>
<button onClick={handleUpload}>Post Video</button><button onClick={cancel}>Cancel</button>
</div> :
<div>
<button onClick={handleRecord}>Record</button>
</div>
}
</div>
}
{
showAuthModal &&
<AuthModal />
}
</div>
)
}

export default Header;
import { useEffect, useState } from 'react'
import { useConnect, useAccount } from 'wagmi'

type props = {
setShowAuthModal: (status: boolean) => void
}

export default function AuthModal(props: props) {
const { setShowAuthModal } = props;
const [showConnector, setShowConnector] = useState(false)
const { isConnected } = useAccount()
const { connect, connectors, isLoading, pendingConnector } = useConnect()

useEffect(() => {
if (isConnected) {
setShowAuthModal(false);
}
}, [isConnected]);

const connectWallet = () => {
setShowConnector(true)
}

return (

<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex flex-col min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">

<div>

<div className="mt-3 text-center sm:mt-5">
<h3
className="text-lg font-medium leading-6 text-gray-900"
>
Connect Your Farcaster Account
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
In order to post new videos or interact with existing videos on Absorb, you must have a Farcaster account. Connect your wallet to begin.
</p>
</div>
</div>
</div>
{!showConnector && (
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
onClick={connectWallet}
type="button"
>
Connect Wallet
</button>
<button
type="button"
onClick={() => setShowAuthModal(false)}
>
Cancel
</button>
</div>
)}

{showConnector && (
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
{connectors.map((connector) => (
<button
disabled={!connector.ready}
key={connector.id}
type="button"
onClick={() => connect({ connector })}
>
{connector.name}
{!connector.ready && ' (unsupported)'}
{isLoading &&
connector.id === pendingConnector?.id &&
' (connecting)'}
</button>
))}
<button
type="button"
onClick={() => setShowAuthModal(false)}
>
Cancel
</button>
</div>
)}
</div>
</div>
)
}
export const getMediaRecorder = async (previewUrl: string, setAmountOfCameras: (device: number) => void, strm: any, setStream: (stream: any) => void) => {
if (!previewUrl) {
if (
navigator.mediaDevices &&
navigator.mediaDevices.getUserMedia
) {
navigator.mediaDevices
.getUserMedia({
audio: false,
video: true,
})
.then(function (stream) {
stream.getTracks().forEach(function (track) {
track.stop()
})

getDeviceCount().then(function (deviceCount: any) {


// setAmountOfCameras(deviceCount);

// init the UI and the camera stream
setAmountOfCameras(deviceCount)
initCameraStream("user", strm, setStream)
})
})
.catch(function (error) {
if (error === 'PermissionDeniedError') {
alert('Permission denied. Please refresh and give permission.')
}

console.error('getUserMedia() error: ', error)
})
} else {
alert(
'Mobile camera is not supported by browser, or there is no camera detected/connected'
)
}
}
}

export const getDeviceCount = () => {
return new Promise(function (resolve) {
var videoInCount = 0

navigator.mediaDevices
.enumerateDevices()
.then(function (devices: any) {
devices.forEach(function (device: any) {
if (device.kind === 'video') {
device.kind = 'videoinput'
}

if (device.kind === 'videoinput') {
videoInCount++
console.log('videocam: ' + device.label)
}
})

resolve(videoInCount)
})
.catch(function (err) {
console.log(err.name + ': ' + err.message)
resolve(0)
})
})
}

const initCameraStream = (mode: string, stream: any, setStream: (strm: any) => void) => {
if (stream) {
stream.getTracks().forEach(function (track: any) {
console.log(track)
track.stop()
})
}

let constraints= {
audio: true,
video: {
width: 1080,
height: 1920,
facingMode: mode,
aspectRatio: { exact: 1.7777777778 }
},
}


navigator.mediaDevices
.getUserMedia(constraints)
.then((stream) => {
const video: any = document.getElementById('video')
if (video) {
video.srcObject = stream
}

setStream(stream)

const track = stream.getVideoTracks()[0]
const settings = track.getSettings()
const str = JSON.stringify(settings, null, 4)
console.log('settings ' + str)
})
.catch(handleError)
}

function handleError(error:any) {
console.error('getUserMedia() error: ', error)
alert("Error getting media")
}
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
import canonicalize from "canonicalize";

export type TokenPayload = {
secret: string;
}

export type FC_Auth = {
token: TokenPayload;
}

export const getAuthTokenFromFC = async (bearer: string, payload: string) => {
const config: AxiosRequestConfig = {
headers: {
"Content-Type": "application/json"
}
}
const res:AxiosResponse = await axios.post("/api/auth", {payload, bearer}, config)
return res.data;
}

export const generateAuthToken = () => {
const now = Date.now();

const params = {
method: "generateToken",
params: {
timestamp: now,
expiresAt: now + 600000,
},
}

const payload = canonicalize(params);
if (payload === undefined)
throw new Error("failed to canonicalize auth params");
return payload;
}

export const fetchProfile = async () => {
try {
const auth: FC_Auth = JSON.parse(localStorage.getItem("fc-token") || "")
if(!auth) {
throw "Not authenticated";
}

const res = await axios.get("/api/auth", {
headers: {
Authorization: auth.token.secret
}
});

return res.data;
} catch (error: any) {
console.log(error);
console.log(error.message);
return null;
}
}

export const fetchFeed = async () => {
try {
const res = await axios.get("/api/feed")
return res.data
} catch (error) {
console.log(error);
alert("Error fetching feed");
}
}
import axios from "axios";
import { MerkleAPIClient } from "@standard-crypto/farcaster-js";
import { NextApiRequest, NextApiResponse } from "next";


export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const data = req.body.payload;

var config = {
method: 'put',
url: 'https://api.farcaster.xyz/v2/auth',
headers: {
'Accept': 'application/json, text/plain, */*',
'Authorization': req.body.bearer,
'Content-Type': 'application/json'
},
data: data
};

const response = await axios(config);
res.json(response.data.result);
} else if (req.method === "GET") {
try {
const apiClient = new MerkleAPIClient({
secret: req.headers.authorization || ""
});
const user = await apiClient.fetchCurrentUser();

return res.json(user);
} catch (error: any) {
console.log(error);
if (MerkleAPIClient.isApiErrorResponse(error)) {
const apiErrors = error.response.data.errors;
for (const apiError of apiErrors) {
console.log(`API Error: ${apiError.message}`);
}
console.log(`Status code: ${error.response.status}`);
res.status(error.response.status).json(apiErrors)
} else {
res.status(500).send(error.message);
}
}
} else {
res.status(200);
}
}
import axios from "axios";
import { NextApiRequest, NextApiResponse } from "next";
const pinataSDK = require('@pinata/sdk');
const pinata = new pinataSDK({ pinataJWTKey: process.env.PINATA_API_KEY });

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// No post requests here...
} else if (req.method === "GET") {
try {
const metadata = {
farcaster_video: {
value: "true",
op: "eq"
}
}
const filters = {
pageOffset:req.query.offset || 0,
status: "pinned",
metadata
}
const feed = await pinata.pinList(filters);
res.json(feed);
} catch (error: any) {
res.status(500).send(error.message);
}
} else {
res.status(200);
}
}
PINATA_API_KEY=YOUR PINATA JWT
.env.local
import type { NextApiRequest, NextApiResponse } from 'next'
import { MerkleAPIClient } from "@standard-crypto/farcaster-js";
import formidable from "formidable";
import fs from "fs";
import FormData from "form-data";
const pinataSDK = require('@pinata/sdk');
const pinata = new pinataSDK({ pinataJWTKey: process.env.PINATA_API_KEY });

export const config = {
api: {
bodyParser: false,
}
};

const saveFile = async (file: any) => {
try {
const stream = fs.createReadStream(file.filepath);
let data: any = new FormData();
data.append('file', stream);
const options = {
pinataMetadata: {
keyvalues: {
farcaster_video: 'true'
}
}
};
const response = await pinata.pinFileToIPFS(stream, options);

fs.unlinkSync(file.filepath);

return response.data;
} catch (error) {
throw error;
}
}

export default async function handler(
req: NextApiRequest,
res: NextApiResponse<String>
) {
if (req.method === "POST") {
try {
// Check for logged in user
const apiClient = new MerkleAPIClient({
secret: req.headers.authorization || ""
});
const user = await apiClient.fetchCurrentUser();
if(!user) {
return res.status(401).send("Unauthorized");
}
const form = new formidable.IncomingForm();
form.parse(req, async function (err, fields, files) {
if (err) {
console.log({ err })
throw err;
}
const response = await saveFile(files.file);
const { IpfsHash } = response;

const text = `New video from @${user.username}\n View it https://gateway.pinata.cloud/ipfs/${response.hash}`

await apiClient.publishCast(text)
return res.status(200).send(IpfsHash)
});
} catch (error) {
console.log(error);
res.send("Server Error");
}
} else if(req.method === "OPTIONS") {
return res.status(200).send("ok");
}
}
const handleRecord = async () => {
if (recording === true) {
setRecording(false)
mediaRecorder.stop()
setTime(7)
} else {
setRecording(true)
const media = new MediaRecorder(strm, { mimeType: mimeType })
setMediaRecorder(media)
let blobs_recorded: any = []

media.addEventListener('dataavailable', function (e) {
blobs_recorded.push(e.data)
})

media.addEventListener('stop', async function () {
const blob = new Blob(blobs_recorded, { type: 'video/mp4' })

setBlob(blob)
let video_local = URL.createObjectURL(blob)
const previewVideo: any = document.getElementById('preview')
previewVideo.src = video_local
setPreviewUrl(video_local)
setRecording(false)
setTime(7)
strm.getTracks().forEach((track: any) => {
track.stop()
track.enabled = false
})
const audioContext = new AudioContext()
audioContext.close
const microphone = audioContext.createMediaStreamSource(strm)
microphone.disconnect
})

media.start()

setTimeout(() => {
if (media.state === 'recording') {
media.stop()
}
}, 7000)
}
}
  useEffect(() => {
if (time && recording) {
setTimeout(() => {
setTime(time - 1)
}, 1000)
}
}, [time, recording])
 <div>
{recording ? <span>{time.toString()}</span> : <button onClick={handleRecord}>Record</button>}
</div>
  const handleUpload = async () => {
if (!previewUrl) {
alert('Must have a video')
return
}
setPosting(true)

const FID = user.fid
const form:any = new FormData()
form.append(
'video',
blobData,
`${FID}-${Date.now()}.${mimeType.split('/')[1]}`
)

try {
const res:AxiosResponse = await axios.post('/api/upload', form, {
maxBodyLength: Infinity,
headers: {
'Content-Type': `multipart/form-data; boundary=${form._boundary}`,
Authorization: JSON.parse(localStorage.getItem('fc-token') || "").token
.secret,
},
})
console.log('upload: ', res.data)
setPosting(false)
cancel()
setRecordView(false)
loadPosts()
} catch (error:any) {
console.log(error)
setPosting(false)
alert(error.message)
}
}
{
posting ?
<div>
Uploading...
</div> :
<div>
<button className="mr-2" onClick={handleUpload}>Post Video</button><button className="ml-2" onClick={cancel}>Cancel</button>
</div>
}
import Feed from '../components/Feed'
import Header from '../components/Header'
import axios from "axios";
import { useState, useEffect } from 'react';
const pinataSDK = require('@pinata/sdk');
const pinata = new pinataSDK({ pinataJWTKey: process.env.PINATA_API_KEY });

export default function Home(props: any) {
const [posts, setPosts] = useState<any>(props.data);

const loadPosts = async () => {
try {
const res = await axios.get("/api/feed");
const data = res.data;
setPosts(data.rows);
} catch (error) {
alert("Trouble loading posts");
}
}
return (
<div className="w-full">
<Header loadPosts={loadPosts} />
<Feed posts={posts} />
</div>
)
}

export async function getStaticProps() {
const metadata = {
keyvalues: {
farcaster_video: {
value: "true",
op: "eq"
}
}
}
const filters = {
pageOffset: 0,
status: "pinned",
metadata
}
const feed = await pinata.pinList(filters);

return {
props: {
data: feed.rows
},
revalidate: 10,
}
}
import React from 'react'

type props = {
posts: any[]
}

const Feed = (props:props) => {
const { posts } = props;

return (
<div className="mt-20">
{
posts.map(p => {
return (
<div key={p.ipfs_pin_hash}>
<video
src={`https://gateway.pinata.cloud/ipfs/${p.ipfs_pin_hash}`}
playsInline
autoPlay
muted
controls
loop
id={p.cid}
></video>
</div>
)
})
}
</div>
)
}

export default Feed

--

--

The cloud wasn’t built for this. Pinata was. Managing your NFT media just got easier.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store