Cloudinary Blog

Improving Instagram Web: New Features for a Better User Experience

Improving Instagram Web: New Features for a Better User Experience

My work demands that I stay away from my phone and mobile notifications in order to be as productive as possible each day. It’s not unusual to find me at my desk for a total 12 hours a day (I work remotely), with four hours going to browsing the internet.

As I sniff around, Instagram Web is open constantly, with a visit rate of 2.4 percent of my daily hours. I don’t think this is just me, because in the U.S. alone, 17 percent of Instagram traffic comes from desktops.

As an engineer, I have a tendency to try to improve a tool (productivity or fun) I use everyday. For me, it is Instagram. So, I assembled a list of possible Instagram Web improvements I can work on, built a simple Instagram clone, then improved on those points. In this article, I’ll talk more about what I’ve done, and you might even see more room for improvements.

Needed Changes

Maybe the Instagram team thought of the points I’m about to discuss, and had their reasons for not making these types of changes. Or, maybe it never occurred to them to do these things. Who knows? Here’s list the features I wish I had every time I access Instagram from my computer (NOT my phone):

  1. Scroll to Play: I keep asking myself why this is not a feature on the web, yet it is on mobile. After a long session using scrolling infinitely on Instagram mobile, I forget that you have to click a video on Instagram Web before it plays. Most time, I just wait thinking my network has slowed down again until I snap back. What exact reason is Instagram Web refusing to play videos? Who knows?

  2. Hover to Mute and Unmute: Yeah, I can afford a hover you know? I am on a desktop, not a mobile phone. A hover event is my well-earned right for buying a large computer. Why not utilize it, Instagram Web? Oh yes, I can click to play and pause — I know that. But what if I want the videos to play without a sound? Hover could do that well.

  3. Progressive Image Loading: We all know that images are optimized and the web app probably uses resolution switching to serve the best image resolution for a browser view port. I live in Africa where slow 3G is a norm, so instead of slashing that grey background at users on a poor network, maybe you can borrow some ideas from Medium and load the images progressively.

How I Intend to Make Improvements

Of course I don’t have a lot of time, so I can’t imagine building some of these features from scratch. Oh, unless you’re paying me…then we can talk. What I intend to do is use a third-party service - Cloudinary - that solves most of these problems. Cloudinary is an end-to-end, cloud-based media management solution. It offers media (images and videos) storage, transformation and delivery.

Here is a list of where Cloudinary could help improve the app:

  • Video/image delivery and upload
  • Cropping and padding transformation
  • Width and height transformation
  • Progressive loading
  • Optimization
  • Media storage
  • Video Player with Scroll to Play

Setting Up the App

Instagram Web was built with React, so, I am going to prove that the limitations are not tool-specific by using React in the examples. Run the following command to create a React app:

# Install CRA
npm install -g create-react-app

# Create a New App
create-react-app <app-name>

Persisting Payloads with a Simple API Server

We need a simple data persistence mechanism to store a list of posts. Each post would have the basic requirements, including nickname, avatar, caption and post media url. We can use file storage on a server to keep the posts as JSON files and read and write from them.

Server Requirements/Dependencies and Configurations

To have the appropriate server running, you would need the following:

  • node: Almighty JS on the server
  • express: HTTP routing framework for Node
  • body-parser: A middleware to parse HTTP body and attach the content to the req object
  • cors: express middleware to enable CORS
  • low: a small local JSON database powered by Lodash
  • uuid: Generates UUID to serve as unique IDs for each posts

Install the server dependencies by running:

npm install express body-parser cors lowdb uuid

Note
You need to have node installed before running the above command

Next, create a server.js file on the root of the React project. Then import the dependencies and configure express:

// Import Dependencies
const Express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
const uuid = require('uuid/v4');

// Create an Express app
const app = Express();

// Configure middleware
app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

// Configure database and use a file adapter
const adapter = new FileSync('db.json');
const db = low(adapter);

// Choose a port
app.set('port', 8070);

/*
*
* [ R O U T E S    H E R E]
*
*/

// Listen to the chosen port
app.listen(app.get('port'), _ => console.log('App at ' + app.get('port')));

You can start running the app now:

node server.js

# OR

nodemon server.js

HTTP Routes

The React app is going to make HTTP requests to this server and point to a route while doing so. We need to define these routes and implement the behavior when they are visited. The first route creates a single post:

app.set('port', 8070);
//
app.post('/posts', (req, res) => {
  // Assemble data from the requesting client
  // Also assign an id and a creation time
  const post = Object.assign({}, req.body, {
    id: uuid(),
    created_at: new Date()
  });

  // Create post using `low`
  db
    .get('posts')
    .push(post)
    .write();
  // Respond with the last post that was created
  const newPost = db
    .get('posts')
    .last()
    .value();
  res.json(newPost);
});
//
app.listen(app.get('port'), _ => console.log('App at ' + app.get('port')));

I am using the express instance to handle a post request using the post instance method. When the request comes in, and the URL matches http://localhost:8070/posts, the callback function passed to this instance method is called. The route just assembles the data received from the client, stores it and sends it back to the client as a confirmation that the persisting process was successful.

Almost the same pattern goes for fetching all the available posts:

app.post('/posts', (req, res) => {});
//
app.get('/posts', (req, res) => {
  // Fetch a post based on the
  // offset and limit
  const posts = db
    .get('posts')
    .orderBy(['created_at'], ['desc'])
    .slice(parseInt(req.query.offset) - 1)
    .take(parseInt(req.query.limit))
    .value();

  // Get the total count
  const count = db.get('posts').value().length;
  // Send a response
  res.json({ posts: posts, count: count });
});
// 
app.listen(app.get('port'), _ => console.log('App at ' + app.get('port')));

In addition to returning a queried list of posts, we also get the total number of posts and send it to the client. This would enable the client implement features like pagination or infinite scroll.

Getting Ready for the Client (React) App

Leave the React app running and watching for changes by running the following command:

yarn start

Empty whatever is contained in the ./src/App.js file so we can start on a blank slate. Replace with:

import React, { Component } from 'react';

import Header from './Header';

class App extends Component {
  render() {
    return (
      <div className="App">
        <Header />
      </div>
    );
  }
}

export default App;

We trimmed it down to just showing a nav header. Create the header by adding a folder named Header to src and a file in the folder named index.js:

import React from 'react';
import './Header.css';

const Header = () => (
  <nav className="Nav">
    <div className="Nav-menus">
      <div className="Nav-brand">
        <a className="Nav-brand-logo" href="/">
          Instagram
        </a>
      </div>
    </div>
  </nav>
);

export default Header;

Nothing functional, just a fancy header when you add Header.css and image sprite from this commit history:

Instagram

Showing a Post

Just like the header, you need a few JSX and styles to show a post prototype on the screen. Add another component Post with a root index and add the following:

import React from 'react';
import './Post.css';

const Post = () => (
  <article className="Post">
    <header>
      <div className="Post-user">
        <div className="Post-user-avatar">
          <img
            class="_rewi8"
            src="https://instagram.flos6-1.fna.fbcdn.net/t51.2885-19/s150x150/14727482_199282753814164_8390284987160592384_a.jpg"
          />
        </div>
        <div className="Post-user-nickname">
          <span>ogrant718</span>
        </div>
      </div>
    </header>
    <div className="Post-image">
      <div className="Post-image-bg">
        <img
          src="https://instagram.flos6-1.fna.fbcdn.net/t51.2885-15/e35/24845932_1757866441186546_5996861590417178624_n.jpg"
          alt=""
        />
      </div>
    </div>
    <div className="Post-caption">
      <strong>ogrant718</strong> Drops at midnight
    </div>
  </article>
);

export default Post;

This commit history has the styles, so you can create Post.css in the same root as index.js and add the styles.

Update the App.js component to include the new post component:

//
import Post from './Post';

class App extends Component {
  render() {
    return (
      <div className="App">
        ...
        <section className="App-main">
          <Post />
        </section>
      </div>
    );
  }
}

export default App;

Save, and once your browser reloads, you should get the following result:

Instagram

We can’t have just a single post, we need a list of them. Let’s get to that!

Render a List of Posts

We need to render a list of posts, and to do that we need another component to contain the Post component and then iterate over an array of data and display a Post component for each item in the array. Create another component Posts:

import React from 'react';
import Post from '../Post';
import './Posts.css';

const Posts = () => (
  <div className="Posts">
    {([1, 2, 3]).map(v => <Post />)}
  </div>
);

export default Posts;

There is no array of data coming from the server yet, so we just iterate through an array of static figures. Now you can replace the Post in App with Posts:

<section className="App-main">
  <Posts />
</section>

And there, you have a list of Instagram posts:

Instagram

Connect Real Data

Rather than iterating over numbers, why not make a request to your server (which is running) and ask for a list of posts. I already populated the repo with a list of posts. Update the App component to request for them:

// Import axios
import axios from 'axios';

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      posts: [],
      offset: 1,
      limit: 4
    };
    this.baseUrl = 'http://localhost:8070/posts';
  }

  get constructFetchUrl() {
    const { offset, limit } = this.state;
    return `${this.baseUrl}?offset=${offset}&limit=${limit}`;
  }

  componentDidMount() {
    axios
      .get(this.constructFetchUrl)
      .then(({ data }) => (this.setState({posts: data})))
      .catch(err => console.log(err));
  }

  render() {
    return (
      <div className="App">
        <Header />
        <section className="App-main">
          <Posts posts={this.state.posts} />
        </section>
      </div>
    );
  }
}

export default App;

The Posts component now receives a state named props, which is populated after an ajax request. To make this request, you need to install axios, a HTTP library:

yarn add axios

Next, use the componentDidMount lifecycle method to hook-in and make the Ajax request once the React component is read. The axios request returns a promise, which, when resolved, has a payload of posts. You can update the state once the promise is resolved.

You need to receive the posts in the Posts component and replace the array of static numbers with it:

const Posts = ({posts}) => (
  <div className="Posts">
    {posts.map(post => <Post {...post} key={post.id}  />)}
  </div>
);

Then update the static JSX content in the Post component so it shows the actual data from the server:

import React from 'react';

const isVideo = url => url.split('.')[url.split('.').length - 1] === 'mp4';

const Post = ({ user: { nickname, avatar }, post: { image, caption } }) => (
  <article className="Post">
    <header>
      <div className="Post-user">
        <div className="Post-user-avatar">
          <img src={avatar} alt={nickname} />
        </div>
        <div className="Post-user-nickname">
          <span>{nickname}</span>
        </div>
      </div>
    </header>
    <div className="Post-image">
      <div className="Post-image-bg">
        {isVideo(image) ? (
          <video
            className=""
            playsInline
            poster="http://via.placeholder.com/800x800"
            preload="none"
            controls
            src={image}
            type="video/mp4"
          />
        ) : (
          <img
            src={image}
            alt=""
          />
        )}
      </div>
    </div>
    <div className="Post-caption">
      <strong>{nickname}</strong> {caption}
    </div>
  </article>
);

export default Post;

Remember Instagram supports two kinds of posts — images and videos. The isVideo method checks if the URL is a video and then uses the video tag to display it. For everything else, it uses the image tag.

Using the Cloudinary Video Player

Nothing is wrong with the HTML5 video player you saw above. But there will be a problem soon. For us to transform the videos to our taste, and have 100 percent control over the process, we need to use a third-party library. Cloudinary open sourced a video player recently that handles every video player problem you can think of. You can install the player via npm:

yarn add cloudinary-core cloudinary-video-player

Note
cloudinary-video-player is the actual library for playing videos while cloudinary-core is the general Cloudinary JavaScript SDK for delivering media contents and transforming them.

Import the libraries to the Post component:

import cloudinary from 'cloudinary-core';
import 'cloudinary-video-player';

// Video Player CSS
import '../../node_modules/cloudinary-video-player/dist/cld-video-player.min.css';

Create an instance of the SDK and the video player and mount the video player instance on the HTML5 video tag we had using ref:

class Post extends Component {
  constructor(props) {
    super(props);
    this.cl = cloudinary.Cloudinary.new({ cloud_name: 'christekh' });
    this.vDom = null;
    this.vPlayer = null;
  }
  componentDidMount() {
    if (this.vDom) {
      this.vPlayer = this.cl.videoPlayer(this.vDom);
      this.vPlayer.source(this.fetchPublicId(this.props.post.image));
    }
  }

  isVideo = url => url.split('.')[url.split('.').length - 1] === 'mp4';

  render() {
    // ...
      <video
        controls
        loop
        id={this.vId}
        className="cld-video-player"
        ref={vDom => (this.vDom = vDom)}
    //...
  }
}
export default Post;

Notice the component has been updated from functional to class. We need to maintain internal state with instance properties to keep track of things.

Image and Video Transformation

When you define width and height dimensions for an image or a video, you are not certain if the user adheres to your instructions and upload that dimension. In fact, a social network app would hardly recommend a dimension. Instead, it would accept whatever dimension the user sends and try as much as possible to make it fit into a design.

Assuming we define a strict width and height of 687px for the image and video, you may end up getting poorly scaled contents. With Cloudinary transformations, we can intelligently adapt the content in a way that it retains quality and we have consistence dimension.

Transforming Images The SDK lets you exposes a url method that takes in an the public ID of a Cloudinary image and returns the full URL. We’ve got no public ID but we have the full URL. The Public ID is the string right before the file extension in the URL. I wrote a method to extract it:

isVideo = url => url.split('.')[url.split('.').length - 1] === 'mp4';
//...
fetchPublicId = url =>
    url.split('/')[url.split('/').length - 1].split('.')[0];

Now you can call the url method:

<img alt={caption} src={this.cl.url(this.fetchPublicId(image), {
    width: 687,
    height: 687,
    crop: 'pad',
  })} />

It takes the publicID and an optional transformation/configuration object. The images now have defined width and height and any image that doesn’t fit is padded.

Progressive Loading and Optimization You will be amazed at how it simple it is to optimize images and load them progressively:

<img alt={caption} src={this.cl.url(this.fetchPublicId(image), {
    width: 687,
    height: 687,
    crop: 'pad',
    flags: 'progressive:steep',
    quality: 'auto'
  })} />

The flags value, progressive:steep loads a poor quality as fast as possible and then progressively updates the image with a higher quality image until it reaches a reasonable stage. Setting quality to auto enables the image to continue optimizing as long as the optimization is lossless (quality doesn’t drop).

Transforming Videos You can transform videos by passing a second argument, which is in the form of a config object to the videoPlayer method:

componentDidMount() {
    if (this.vDom) {
      this.vPlayer = this.cl.videoPlayer(this.vDom, {
          transformation: {
            width: 687,
            height: 687,
            crop: 'pad'
          }
        });
    }
}

Hover to Mute/Unmute

Let’s discuss one of my major concerns - hovering to mute and unmute. Now that we have videos setup, this is a perfect time to start tackling this feature. Here is the plan:

  1. Mute videos on load
  2. Unmute on mouse enter
  3. Mute on mouse leave

Let’s start with muting the videos by default. This will still be done in componentDidMount, as soon as we configure the video:

componentDidMount() {
  if (this.vDom) {
    this.vPlayer = this.cl.videoPlayer(this.vDom, {
      //...
    });
    this.vPlayer.source(this.fetchPublicId(this.props.post.image));
    // Mute by default
    this.vPlayer.mute();
  }
}

Then once a user points a mouse to the video or moves the mouse off the video, we want to mute:

<div
  className="Post-image-bg"
  onMouseEnter={this.unMutePlayer}
  onMouseLeave={this.mutePlayer}
>
  /* video and image tags*/
</div>

The onMouseEnter is used to attach the unMutePlayer event to the video container and vice versa. You can create these methods to trigger the mute and unmute:

class Post extends Component {
  //....  
  mutePlayer = e => this.vDom && this.vPlayer.mute();
  unMutePlayer = e => this.vDom && this.vPlayer.unmute();
}

Hide Bottom Controls

For an Instagram use case, the bottom control as shown in the image below is not so useful and it is distracting:

Instagram

We can hide it by adding a *vjs-controls-disabled* CSS class to the player:

<video
    ...
    className="cld-video-player vjs-controls-disabled"
    />

Fluid Videos

Making the video player responsive is as simple as it can get. Call the cld-fluid method on the player instance and pass the method true to get it kicking:

this.vPlayer.fluid(true);

Scroll Hooks

Everyone’s favorite feature about social network is infinite scroll — ability to load more content when you get to the bottom of the content. To implement this feature, you need a scroll hook — something that happens when a scroll occurs. It’s just an event that we can add to the App component:

constructor(props) {
  ...
  this.state = {
    //...
    clientCount: 3,
    serverCount: 0,
    hasMorePosts: false
  };
  this.fetching
  window.addEventListener('scroll', () => {
    if (
      this.bottomVisible &&
      this.fetching === false &&
      this.state.hasMorePosts
    ) {
      this.loadPosts(this.state.offset, this.state.limit);
    }
  });
}

componentDidMount() {
  this.loadPosts(this.state.offset, this.state.limit);
}

When you scroll, the event is fired, and it checks if the bottom of the page is visible before loading posts. It also checks if the fetching instance variable is false (it’s set to true when a request is going on). Finally, it checks if there are more posts that the server needs to send.

The bottomVisible is a getter that returns a boolean if we have scrolled to the bottom:

get bottomVisible() {
  const scrollY = window.scrollY;
  const visible = document.documentElement.clientHeight;
  const pageHeight = document.documentElement.scrollHeight;
  const bottomOfPage = visible + scrollY >= pageHeight;
  return bottomOfPage || pageHeight < visible;
}

I moved loadPosts into a method so we can reuse it both in componentDidMount and when we scroll to the bottom. Here is the updated version:

loadPosts(offset, limit) {
  this.fetching = true;
  axios
    .get(this.constructFetchUrl(offset, limit))
    .then(({ data: { posts, count } }) => {
  this.setState(
        {
          posts: [...this.state.posts, ...posts],
          serverCount: count,
          hasMorePosts: this.state.clientCount < count ? true : false,
          offset: this.state.offset + this.state.limit,
          clientCount: this.state.clientCount + 3
        },
        () => {
          this.fetching = false;
        }
      );
    })
    .catch(err => console.log(err));
}

Scroll to Play

Another limitation of the Instagram Web is that unlike Instagram mobile, videos don’t play when they are scrolled into the viewport. Cloudinary solves this with a simple configuration:

this.vPlayer = this.cl.videoPlayer(this.vDom, {
  // Play video when in viewport
  autoplayMode: 'on-scroll',
  transformation: {
    //.....
  }
});

One Last Word

I made this prototype clone in a day — in fact, while waiting for a delayed flight. However, such a quick turnaround would not have been the case if I had chosen the route of implementing the whole transformation and video features myself. Cloudinary does that for you, so you can focus more on writing business-specific code and not utility logics. Let me know your thoughts in the comments section below.

CODE

Recent Blog Posts

CoreMedia Adds Cloudinary to its CoreMedia Studio Platform

Today we’re pleased to announce a new technology partnership with CoreMedia, a leading Content Experience Platform provider. CoreMedia users can now leverage Cloudinary’s web-based digital asset management (DAM) solution to organize, search, manage and optimize their media assets, including images and videos, and to orchestrate, preview and deliver digital experiences consistently and optimized across all channels and browsers. The official press release is available here.

Read more
Facial-Surveillance System for Restricted Zones

In Africa, where Internet access and bandwidth are limited, it’s not cost-effective or feasible to establish and maintain a connectivity for security and surveillance applications. That challenge makes it almost impossible to build a service that detects, with facial-recognition technology, if someone entering a building is authorized to do so. To meet the final-year research requirement for my undergraduate studies, I developed a facial-surveillance system. Armed with a background in computer vision, I decided to push the limits and see if I could build a surveillance system that does not require recording long video footage.

Read more
Complex Networks Case Study

Complex Networks has been using Cloudinary since 2014 to manage and optimize images across seven websites and two mobile apps, making editorial workflow more efficient, improving page performance and load time, and increasing user engagement. Cloudinary was instrumental in enabling Complex Networks to redesign its web properties. Without the flexibility that Cloudinary offers to both creative and development teams, it would not have been possible for Complex Networks to achieve such a fast time to market.

Read more
Automate Placeholder Generation and Accelerate Page Loads

If you run a Google search on LQIP you’ll see very few relevant articles, very little guidance, and definitely no Wikipedia articles. In this post, we’ll discuss some of the feedback on LQIP we have gathered from the community and suggest and open for conversation a few approaches based on the built-in capabilities of the Cloudinary service. Specifically, we’ll explain what LQIP are, where they are best used, and how you can leverage them to accelerate page loads and optimize user experience.

Read more
Best Practices for Optimizing Web Page Speed

If you're like most consumers today, you engage more with pictures or videos on a website than text. The stats don't lie - four times as many visitors would rather watch a video about a product than read about it, and sites with compelling images average twice as many views as text-heavy ones.

Read more
A day of fun with Girls Who Code and Cloudinary

During both my computer science studies and work in the tech field, there have not been a lot of women present. While our ranks have grown, women still make up only a small percentage. In many ways, I think the traditionally male-dominated world can be intimidating to women and girls who may be interested in pursuing these types of tech careers.

Read more