This single-page web app can edit itself

How to create a self-editing, (im)mutable web profile, and have fun doing it

What we’ll be building in this tutorial… a decentralized, editable profile app.

The official IPFS Javascript library has undergone several major updates recently that make it even better for building decentralized web-apps and DWeb sites. With the advent of IPNS support in js-ipfs, app developers can now support ‘mutable’ IPFS content and near real-time updates. This means we can start to build interactions with DWeb technologies that provide the same (or better!) experience that users have come to expect from most Web2 technologies! As you probably already know, a positive Dweb user experience is a big part of our focus at Textile, so any time we see an opportunity to test out new tools that can help us make this happen, we get excited.

To get a taste for what IPNS in the browser can bring, we thought it might be fun to build a simple profile app, something akin to an about.me page or similar. While we’re building this app, we’ll use Knockout to simplify our user interface using a model-view view-model (MVVM) pattern . For those who wan to skip ahead to the end, you can clone and play around with the full working app right away, for the rest of you, let’s get started…

Getting started

Like many of our previous posts, we’ll start by cloning Textile’s handy-dandy ipfs app template.

git clone https://github.com/textileio/dapp-template.git profile-app
cd profile-app

While you’re at it, go ahead and grab this IPFS getter module, which makes it easy to use promises when creating a new IPFS peer/node in the browser. You can save it in your projects src directory and call it something like ipfs-promise.js.

Ok, with the project cloned and our new file ready to go, the only other thing you’ll want to do is yarn install the required modules, and then yarn watch your code so we can start building and testing our app.

yarn install
yarn watch

Once your project is built, the watch command will ‘watch’ for changes and update your compiled bundle as needed (we’re using browserify). So you should fire up http://localhost:8000 in your browser and refresh that page along the way each time you make any changes to your code. You can also check out this previous tutorial where we cover some of the required modules and development tools we’ll be using in today’s post. Ok, enough setup, let’s get building…

Let’s get building

We’ll start pretty basic, adding only minimal functionality to get us going. So our main.js file gets some minor updates:

Which is essentially just starting up an IPFS peer in the browser, querying for our peer’s id, comparing this with the user id from the url’s query string (that’s how we’ll view other peoples’ profiles, via a user parameter), and console.loging some information. Easy. The app page itself will look pretty much empty, save for a few ‘branding’ bits if you’ve left those in. So that’s great, and hopefully you’re seeing something like this in your browser’s Javascript console:

Viewing profile for Qm… from peer with id Qm…
Viewing own profile, editing will be enabled!

So now, let’s begin designing our profile app!

The View

We’ll start by defining our view. Since we’re developing our web-app with Knockout, our view is defined via our index.html file. Here’s the basic structure:

If you take a look at the main div, you’ll see we have components for our profile image, profile information such as first and last name, etc. We also include a section for work with a list of jobs, as well as a section for a bio write-up, and even social media links. Just for fun, we’ve included some external links to font awesome css assets, just to mix in some ‘centralized’ bits and pieces. You could, of course, include these within your app’s bundle if you wanted, at the expense of a slightly larger bundle size. While you’re at it, you might as well create a style.css file and stick this stuff in it to make things look nice and ‘profile-like’.

The Data Model

With that in mind, we then turn to our data model. For this example, we’ll be using the user-profile data model from our previous tutorial on building a simple decentralized RESTful endpoint with IPFS/IPNS. This is just a simple JSON-based structure with fields for name, work, social media, etc. If you really wanted to get fancy, you could do something like a formalized json-schema based structure, but I leave that as an exercise for the reader. For now, here’s the default structure, which we’ll provide to our app as an importable object (import { defaultProfile } from "./default-profile.js”):

In our code, we’re going to assume that the user either already has this JSON doc published to their IPNS hash, or they’re going to create one using our app. Once we get going, most of our app functionality will go into modifying and republishing this data.

The View Model

Which finally brings us to our view model. This is where we link our user-profile data model (default-profile.js) to our previously defined profile view structure (index.html). We’re going to take advantage of Knockout and the very nice mapping plugin, which gives us a straightforward way to map plain ol’ Javascript objects into a view model with the appropriate observables ready to go. This is awesome, because it really means we’re only adding a few lines to the bottom of our init function in main.js:

// Setup a viewModel based on our JSON structure
const viewModel = ko.mapping.fromJS(defaultProfile)
// Add an 'extra' observable for when we're loading
viewModel.state = {}
viewModel.state.loading = ko.observable(true)
viewModel.state.editing = ko.observable(false)
// Apply the viewModel bindings
ko.applyBindings(viewModel)

But before we save this and reload our page, we’ll also want to create our view’s bindings, so that our view model can actually update our view for us! For that, we return to our index.html file, and add various data-bind properties to bind our view elements to our model via our view-model. You should really check out the Knockout docs for an explanation for all of this fancy binding stuff, but suffice to say, this is how we map our users’ profile information to their profile page. Here’s what the body div from index.html should look like now:

Each view element is mapped in some way, shape, or form to part of our data model. For example, we’ll display a loading spinner if loading is true (and hide the rest). Conversely, for each element in our work array, we’ll display the text for the job title and employer. Any time these elements are updated, our view-model will automatically take care of the changes for us… slick! Alright, after all that, let’s check out our handy work…

Check it out

Here’s what we have so far…

Not all that exciting, so you can change your initial loading bool to false for now, and you’ll likely see something like this:

Simple profile app… but something’s missing 🤔

Ok, now we’re getting somewhere. But an empty profile like that is pretty lame, so let’s start adding some data…

Data over IPFS

Now comes the fancy IPFS part. As is, our app will simply load a default profile every time… we’re not ever trying to load any existing data. So let’s fix that. First things first, let’s try to load a profile if it already exists, based on the user query string or, if that is omitted, the local IPFS peer id. All we really need to add to provide this functionality is a new (async) function:

// Async function that fetches custom ipns json data
const getProfileJSON = async (ipfs, ipns) => {
// Resolve IPNS hash (this is a new feature!)
const name = await ipfs.name.resolve(`/ipns/${ipns}`)
// Now, fetch files...
const files = await ipfs.files.get(`${name.path}/json`)
// Extract binary file contents
const string = String.fromCharCode.apply(null, files[0].content)
// Parse/convert the JSON
return JSON.parse(string)
}

The comments from the code should be pretty self-explanatory. But basically, we’re going to try to resolve the IPNS hash, fetch the json file at the associated IPFS hash, and then convert that binary data to a Javascript object and return it. We’ll call this function at the bottom of our init function, and update our viewModel, assuming all goes well (you can also update the loading state to turn off our spinner if you want):

...
// Get profile information if available
try {
// Grab profile json file via the ipns hash
const data = await getProfileJSON(ipfs, ipns)
// Update our existing viewModel target
ko.mapping.fromJS(data, viewModel)
} catch (err) {
console.log(`${err}: using default profile.`)
}
// Change the loading state to update the view
viewModel.state.loading(false)
...

Editable profiles

If you save and reload the page, you’ll probably still have that same default profile. So wouldn’t it be nice if we could update our profile with out own information? You bet! So let’s add that functionality now.

The following section is going to move pretty fast, because it involves some Knockout magic. Don’t worry so much about the details of the binding handlers, we’ll slow down again once we start publishing things over IPFS. In the mean time, here’s the skinny on getting editable elements to work with Knockout. We need to…

...
import $ from 'jQuery'
// Special handler for contenteditable elements
// Comes from:
// https://stackoverflow.com/questions/19370098/knockout-contenteditable-binding
// Don't worry about this for purposes of this tutorial
ko.bindingHandlers.editable = {
init: function (element, valueAccessor, allBindingsAccessor) {
// const value = ko.unwrap(valueAccessor())
const lazy = allBindingsAccessor().lazy
$(element).on('input', function () {
if (this.isContentEditable && ko.isWriteableObservable(lazy)) {
console.log(this.innerHTML)
lazy(this.innerHTML)
}
})
},
update: function (element, valueAccessor) {
const value = ko.unwrap(valueAccessor())
element.contentEditable = value
if (!element.isContentEditable) {
$(element).trigger('input')
}
}
}
// Don't update view until after we've updated model
ko.bindingHandlers.lazy = {
update: function (element, valueAccessor) {
const value = ko.unwrap(valueAccessor())
if (!element.isContentEditable) {
element.innerHTML = value
}
}
}
  • Update our index.html file to use our new binding handlers (here’s the full file for reference). We’ll use lazy on first, last, title, employer, and bio (I leave the others as an exercise for the reader), plus we’ll make sure they’re editable only when state.editing is true.
  • Update our index.html file with a nice new Edit profile button, which when clicked, will call our viewModel’s handleEdit function (again, see full file for details):
...
// Add function to handle edit profile click
viewModel.handleEdit = async function () {
this.state.editing(!this.state.editing())
}
...

With those changes, you should now be able to save, refresh the page, and have something that looks like this when you click the ✏️ button:

Simple (almost) editable profile…

Publishing updates

Cool! That’s looking pretty good. Now let’s actually update our profile when we are finished editing. Again, this is actually pretty straightforward. We’ll simply add some complexity to our simple handleEdit function:

...
// Function to handle edit profile click
viewModel.handleEdit = async function () {
const editing = this.state.editing()
// Toggle our editing state
this.state.editing(!editing)
if (editing) {
// Export viewModel to JSON
const json = ko.mapping.toJSON(viewModel)
// IPFS add options
const options = {
wrapWithDirectory: true,
onlyHash: false,
pin: true
}
// Create binary buffer from JSON string
const buf = Buffer.from(json)
try {
// Add the new file (same as on desktop)
const res = await ipfs.files.add({
path: 'json',
content: buf
}, options)
// Publish new file to peer's PeerID
const pub = await ipfs.name.publish(res[1].hash)
console.log(`published '${pub.value}' to profile: ${pub.name}`)
window.alert(`published update for profile:\n${pub.name}`)
} catch (err) {
console.log(err)
}
}
}
...

The above function checks if we were previously editing, and if yes, exports our updated data model to JSON, creates a binary buffer that is added to IPFS (wrapped in a directory and named ‘json’), and then publishes the file to our IPNS peer id. Mutable updates to the immutable interplanetary file system!

What is really cool about this last step, is that now anyone viewing our profile page can view, and then actually edit it and create their own version of your profile, and publish it to their own peer id. You can almost think of it like cloning and/or forking in Git speak, though without all the nice versioning. Having said that, all of this stuff is super experimental in js-ipfs. In fact, until the new/compatible DHT lands, the IPNS updates only work on your local node 😫… so you can’t quite use this in a production app, yet.

Ok, with that caveat in mind, let’s also create a very simple ‘edit full profile’ button, so that we can update our social media links and background photo ‘manually’ by editing the profile JSON data directly. In the end, our full profile app should look something like the version I’ve posted here, and you can check out the full project on GitHub:

Final profile app with two editing modes!

That’s a wrap

And there you have it! In this tutorial, we’ve managed to build a fully-working decentralized profile app with minimal code and effort. We’ve kept things pretty simple, but managed to take advantage of real-world programming patterns that make app development a breeze. All in an effort to demonstrate how surprisingly easy it is to develop real-world apps on top of IPFS and its underlying libraries. We’ve even managed to implement some level of mutability on top of the immutable web! So in theory, we could deploy our app ipfs add dist/ and then access our profile, or anyone else’s by accessing the app over an IPFS gateway and entering in their peer id… something like: https://ipfs.io/ipfs/Qmapphash/?user=Qmpeerid 😎.

Thanks for following along! If you like what you’ve read here, why not check out some of our other stories and tutorials, or sign up for our Textile Photos wait-list to see what we’re building with IPFS. While you’re at it, drop us a line and tell us what cool distributed web projects you’re working on — we’d love to hear about them!