Build an Instagram clone with Ionic: Part 3 - Adding data dynamically and enabling realtime functionality
You will need Node 10+, Node Package Manager 6+, Cordova 8+ and Docker 18+ installed on your machine.
The first part of this series focused on building the interface of the application, and the second part on connecting the application to dynamic data in the GraphQL server. This part of this series will walk through creating functionality that enables you to feed data into the data store of the application using GraphQL mutations and allowing users to see posts and comments in realtime.
Prerequisites
- You should have followed through the earlier parts of the series.
- Basic knowledge of JavaScript.
- Node installed on your machine (v10.13.0)
- Node Package Manager installed on your machine (v 6.4.1)
- Cordova installed on your machine (v 8.1.2)
- Docker installed on your machine. (version 18.09.2) Download here.
Uploading posts from the application
At the moment, the homepage of the application looks like this:
The + button at the bottom right has no functionality attached to it. Let’s make the button trigger the addition of new posts. Create a new page that we will take the user to when they click the button.
ionic generate page CreatePost
Registering the new page
Go ahead to add the CreatePostPage
to the declarations
and entryComponents
in the src/app/app.module.ts
:
// src/app/app.module.ts
// [...]
import { CreatePostPage } from '../pages/create-post/create-post';
// [...]
@NgModule({
declarations: [
// [...]
CreatePostPage
],
// [...]
entryComponents: [
// [...]
CreatePostPage
],
// [...]
})
export class AppModule {
// [...]
}
Navigating from the HomePage to the CreatePostPage
Now that we have that set, update the src/pages/home/home.ts
with the createPost
function to navigate to the CreatePostPage
:
// src/pages/home/home.ts
[...]
import { CreatePostPage } from '../create-post/create-post';
@Component({
selector: 'page-home',
templateUrl: 'home.html',
entryComponents: [ProfilePage, CommentPage, CreatePostPage]
})
export class HomePage implements OnInit {
//[...]
public createPost() {
// this function will redirect the user to the createPost page
this.navCtrl.push(
CreatePostPage,
new NavParams({ user_id: "USER_ID_FETCHED_FROM_GRAPHQL_SERVER" })
);
}
}
Note: Currently, the
user_id
is hardcoded. If you want to get yours, navigate to your GraphQL server http://localhost:4466. Run the query to fetch all your users and then pick anid
of your choice:
# GraphQL query on the console to fetch users
query{
users{
id
username
followers
following
}
}
Update the HomePage to navigate to the CreatePostPage
On the home.html
page, update the view to trigger the createPost()
method. Now update your home.html
to look like this:
<!-- src/pages/home/home.html -->
<ion-header>
<ion-navbar>
<ion-title>Instagram Clone</ion-title>
</ion-navbar>
</ion-header>
<ion-content>
<!-- this is where the posts will be -->
<div *ngFor="let post of posts">
<ion-card class="single-post-home">
<ion-item (click)="toProfilePage(post.user.id)">
<ion-avatar item-start>
<img [src]="post.user.avatar">
</ion-avatar>
<h2>{{post.user.username}}</h2>
</ion-item>
<img [src]="post.image_url">
<ion-card-content>
<p>
<strong>{{post.user.username}}</strong> {{post.description}}</p>
</ion-card-content>
<ion-row>
<ion-col>
<button ion-button icon-start clear small (click)="likePost()">
<ion-icon name="heart"></ion-icon>
<div>{{post.likes}} likes</div>
</button>
</ion-col>
<ion-col>
<button ion-button icon-start clear small (click)="toCommentSection(post)">
<ion-icon name="text"></ion-icon>
<div>{{post.comments.length}} Comments</div>
</button>
</ion-col>
</ion-row>
</ion-card>
</div>
<ion-fab bottom right>
<button ion-fab mini (click)="createPost()">
<ion-icon name="add"></ion-icon>
</button>
</ion-fab>
</ion-content>
Adding functionality to the CreatePostPage
Edit your create-post.html
page to look like this:
<!-- src/pages/create-post/create-post.html
<ion-header>
<ion-navbar>
<ion-title>Create a new post</ion-title>
</ion-navbar>
</ion-header>
<ion-content>
<div style="text-align:center; padding: 16px">
<p>Enter a post description and hit <em>Capture Image</em> to create a post</p>
</div>
<div style="display: flex;justify-content: center;align-items: center;flex-direction: column;">
<ion-item style="padding:16px">
<ion-label floating>Post Caption:</ion-label>
<ion-input [(ngModel)]="description" type="text"></ion-input>
</ion-item>
<button style="width:80%; margin-top:20px" ion-button (click)="loadWidget()">
Capture Image
</button>
</div>
</ion-content>
Using the Cloudinary Upload Widget to upload images
To allow image uploads in the application, let’s use the Cloudinary Upload Widget. Cloudinary is a media full stack that enables you to easily handle image and video storage/manipulations in your applications. The best part about the Upload Widget is that it also allows your users to upload images from multiple sources which include: camera, device storage, web address, Dropbox, Facebook, and Instagram.
To get started with Cloudinary first sign up for a free account here. After creating an account, you will need to set up an upload preset that will help you upload to Cloudinary with ease.
Note your Cloudinary
CLOUD_NAME
and CloudinaryUPLOAD_PRESET
for use later in this article.
Include the Cloudinary Widget JavaScript file in the <head>
of your index.html
:
<!-- src/index.html -->
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<!-- other includes -->
<!-- include cloudinary javascript -->
<script src="https://widget.cloudinary.com/v2.0/global/all.js" type="text/javascript"></script>
</head>
<body>
<!-- -->
</body>
</html>
Now, you’re ready to use Cloudinary in your application. Edit the create-post.ts
as follows. First include the necessary modules and declare cloudinary
for use in the application:
// src/pages/create-post/create-post.ts
import { Component } from '@angular/core';
import { IonicPage, NavController, NavParams, AlertController } from 'ionic-angular';
import { Apollo } from 'apollo-angular';
import gql from 'graphql-tag';
import { HomePage } from '../home/home';
import { HttpClient } from '@angular/common/http';
declare var cloudinary;
//[...]
Let’s create a new mutation that will be responsible for creating a post on the GraphQL server we have running:
// src/pages/create-post/create-post.ts
//[...]
// mutation to create a new post
const createUserPost = gql`
mutation createPost($image_url: String!, $description: String, $likes: Int, $postedAt: DateTime!,
$user: UserCreateOneWithoutPostsInput!){
createPost(data: {image_url: $image_url, description: $description, likes: $likes, postedAt: $postedAt, user: $user}){
id
image_url
description
likes
user{
id
username
avatar
}
comments {
id
}
}
}
`;
// [...]
Now we update the create-post.ts
have the functionality for the page:
//src/pages/create-post/create-post.ts
// [...]
@IonicPage()
@Component({
selector: 'page-create-post',
templateUrl: 'create-post.html',
})
export class CreatePostPage {
user_id: string;
uploadWidget: any;
posted_at: string;
image_url: string;
description: string;
constructor(public navCtrl: NavController, public navParams: NavParams, private apollo: Apollo,
public alertCtrl: AlertController, public http: HttpClient) {
// get the user id of the user about to make post
this.user_id = this.navParams.get('user_id');
let self = this;
this.uploadWidget = cloudinary.createUploadWidget({
cloudName: 'CLOUDINARY_CLOUD_NAME',
uploadPreset: 'CLOUDINARY_UPLOAD_PRESET',
}, (error, result) => {
if (!error && result && result.event === "success") {
console.log('Done! Here is the image info: ', JSON.stringify(result.info));
// image link
self.posted_at = result.info.created_at;
self.image_url = result.info.secure_url;
self.uploadPost();
}
})
}
[...]
Be sure to replace the
CLOUDINARY_CLOUD_NAME
andCLOUDINARY_UPLOAD_PRESET
with your credentials.
The constructor of the class gets the user_id
from the navigation parameters and then creates the Cloudinary Upload Widget. We specify the cloudName
, the uploadPreset
and the functionality to execute when the image has been successfully uploaded to Cloudinary.
On successful upload, Cloudinary returns a result
object. From it, we obtain the secure_url
, created_at
for the image and then trigger the uploadPost()
method.
Now, add the other class methods to the CreatePostPage
class:
// src/pages/create-post/create-post.ts
[...]
public uploadPost() {
this.apollo.mutate({
mutation: createUserPost,
variables: {
image_url: this.image_url,
description: this.description,
likes: 10,
postedAt: this.posted_at,
user: { "connect": { "id": this.user_id } }
}
}).subscribe((data) => {
console.log('uploaded successfuly');
// after sucessful upload, trigger pusher event
this.showAlert('Post Shared', 'Your post has been shared with other users');
this.navCtrl.push(HomePage);
}, (error) => {
this.showAlert('Error', 'There was an error sharing your post, please retry');
})
}
public showAlert(title: string, subTitle: string) {
const alert = this.alertCtrl.create({
title: title,
subTitle: subTitle,
buttons: ['OK']
});
alert.present();
}
public loadWidget() {
this.uploadWidget.open();
}
}
The loadWidget()
method, displays the upload widget to the user to upload their image. The uploadPost()
method makes the mutation to the GraphQL server and when that’s complete take the user back to the home page.
Now, your run your application using the command:
ionic serve -c
Navigate to localhost:``8100
on your browser. Now, when you navigate to create a post, you should get a view that looks like this:
Uploading comments on user posts
Earlier in the series, we went through fetching comments on posts from the GraphQL server. Now, let’s walk through how to upload comments on posts to your GraphQL server.
Add the following mutation to your comment.ts
file:
// src/pages/comment/comment.ts
import { Component } from '@angular/core';
import { IonicPage, NavController, NavParams, AlertController } from 'ionic-angular';
import { Apollo } from 'apollo-angular';
import gql from 'graphql-tag';
import { HomePage } from '../home/home';
import { HttpClient } from '@angular/common/http';
const makeComment = gql`
mutation createComment($message: String, $postedAt: DateTime!, $user: UserCreateOneWithoutCommentsInput!,
$post: PostCreateOneWithoutCommentsInput!){
createComment(data: {message: $message, postedAt: $postedAt, user: $user, post: $post}){
id
message
user {
avatar
username
}
}
}
`;
@IonicPage()
@Component({
selector: 'page-comment',
templateUrl: 'comment.html'
})
export class CommentPage {
// [...]
Afterward, add the postComment
to the method CommentPage
class that is responsible for sending the comment to the GraphQL server:
// src/pages/comment/comment.ts
// [...]
export class CommentPage {
// [...] other class variables
post_id : string;
user_comment: string = "";
constructor(
public navCtrl: NavController,
public navParams: NavParams,
private apollo: Apollo,
public alertCtrl: AlertController,
public http: HttpClient,
) {
this.loadComments(this.post_id);
}
// [...] other methods
public postComment() {
this.apollo.mutate({
mutation: makeComment,
variables: {
message: this.user_comment,
postedAt: (new Date()).toISOString(),
user: { connect: { id: "USER_ID_FETCHED_FROM_GRAPHQL_SERVER" } },
post: { connect: { id: this.post_id } }
}
}).subscribe((data) => {
this.showAlert('Success', 'Comment posted successfully');
}, (error) => {
this.showAlert('Error', 'Error posting comment');
});
}
public showAlert(title: string, subTitle: string) {
const alert = this.alertCtrl.create({
title: title,
subTitle: subTitle,
buttons: ['OK']
});
alert.present();
}
}
Note: The user ID was hardcoded to mimic a signed-in user making a comment.
The postComment
method gathers the variables and makes the mutation. Afterwards, a modal is shown to the user to notify them of their successful post.
Finally, in your comment.html
, bind the comment text field to the user_comment
variable and let the button trigger the postComment
method. Update the <ion-footer>
in your comment.html
file to look like this:
<!-- app/pages/comment/comment.html -->
<!-- -->
<ion-footer>
<ion-grid>
<ion-row class="comment-area">
<ion-col col-9>
<ion-textarea placeholder="Enter your comment..." [(ngModel)]="user_comment"></ion-textarea>
</ion-col>
<ion-col col-3>
<button ion-button class="comment-button" (click)="postComment()">
<ion-icon name="paper-plane"></ion-icon>
</button>
</ion-col>
</ion-row>
</ion-grid>
</ion-footer>
Now, the comment section of your application will look like this:
Enabling realtime functionality for posts and comments
Currently, new posts and comments are not updated on all the user devices in real time. This means that other users will have to physically reload their application to see when new posts/comments are made. For a social application, seeing posts and comments as they are made is very important. To add this functionality, let’s use Pusher. Pusher allows you add realtime functionality in your applications with ease.
To get started, sign up for a free Pusher account if you don’t have one yet. Go ahead and create a new Pusher project and then note your PUSHER_APP_ID
, PUSHER_APP_KEY
, PUSHER_APP_SECRET
, PUSHER_CLUSTER
.
Creating a web server that triggers events
Let’s create a simple web server that will trigger events using Pusher when users create new posts and when users add new comments. In your server
directory, initialize an empty Node project:
cd server
npm init -y
Afterward, install the necessary node modules:
npm install body-parser express pusher
express
will power the web serverbody-parser
to handle incoming requestspusher
to add realtime functionality
Now, create a new server.js
file in the server
directory:
touch server.js
Update your server.js
to look like this:
// server/server.js
const express = require('express')
const bodyParser = require('body-parser')
const Pusher = require('pusher');
const app = express();
let pusher = new Pusher({
appId: 'PUSHER_APP_ID',
key: 'PUSHER_APP_KEY',
secret: 'PUSHER_APP_SECRET',
cluster: 'PUSHER_APP_CLUSTER',
encrypted: true
});
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept'
);
next();
});
[...]
This includes the necessary libraries we need and creates a Pusher object using your Pusher credentials obtained earlier and then defines some middleware to handle incoming requests.
The event server will have two routes:
/trigger-post-event
- trigger a new post event on the post channel/trigger-comment-event
- trigger a new comment event on the comment channel
When a user makes a new post, our mobile application makes a request to the /trigger-post-event
of the web server. The web server will then trigger a new-post
event in the post-channel
.
Also, when a new comment is added, our mobile application makes a request to the /trigger-comment-event
of the web server. The web server also triggers a new-comment
event in the comment-channel
.
Later in this tutorial, we will walk through how to listen for
new-post
andnew-comment
events on thepost-channel
andcomment-channel
respectively.
Add the following to your server.js
file:
// server/server.js
[...]
app.post('/trigger-post-event', (req, res) => {
// trigger a new post event via pusher
pusher.trigger('post-channel', 'new-post', {
'post': req.body.post
})
res.json({ 'status': 200 });
});
app.post('/trigger-comment-event', (req, res) => {
// trigger a new comment event via pusher
pusher.trigger('comment-channel', 'new-comment', {
'comment': req.body.comment
});
res.json({ 'status': 200 });
})
let port = 3128;
app.listen(port, () => {
console.log('App listening on port ' + port);
});
Now that the events server is created, you can run it by entering the command:
node server.js
Your server will be available on localhost:3128
as defined in the script. Now, let’s look at how to make requests to the web server from the mobile application.
Creating a Pusher service
To use Pusher in our Ionic application, let’s install the Pusher library:
npm install pusher-js
Afterward, let’s create a simple Pusher service provider that will handle our connection with Pusher:
ionic generate provider pusher-service
In the pusher-service.ts
we create a new Pusher object in the constructor by specifying the PUSHER_APP_KEY
, PUSHER_APP_CLUSTER
. Edit your pusher-service.ts
file to look like this:
// src/providers/pusher-service/pusher-service.ts
import { Injectable } from '@angular/core';
import Pusher from 'pusher-js';
@Injectable()
export class PusherServiceProvider {
pusher: any;
constructor() {
this.pusher = new Pusher('PUSHER_APP_KEY', {
cluster: 'PUSHER_APP_CLUSTER',
forceTLS: true
});
}
postChannel() {
return this.pusher.subscribe('post-channel');
}
commentChannel() {
return this.pusher.subscribe('comment-channel');
}
}
The constructor
method for the class creates a new Pusher object. The postChannel
and commentChannel
methods subscribe to and return the post-channel
and comment-channel
respectively. Earlier in the article, we looked at how to push events from the web server to the post-channel
and comment-channel
. Here we subscribe to those channels so we can listen for events later on.
Now, go ahead to register the PusherServiceProvider
in the app.module.ts
. At this point, your app.module.ts
should look like this:
// src/app/app.module.ts
import { NgModule, ErrorHandler } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { IonicApp, IonicModule, IonicErrorHandler } from 'ionic-angular';
import { MyApp } from './app.component';
// import modules for apollo client
import { HttpClientModule } from '@angular/common/http';
import { ApolloModule, Apollo } from 'apollo-angular';
import { HttpLinkModule, HttpLink } from 'apollo-angular-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
// import other pages
import { HomePage } from '../pages/home/home';
import { TabsPage } from '../pages/tabs/tabs';
import { ProfilePage } from '../pages/profile/profile';
import { CommentPage } from '../pages/comment/comment';
import { CreatePostPage } from '../pages/create-post/create-post';
import { StatusBar } from '@ionic-native/status-bar';
import { SplashScreen } from '@ionic-native/splash-screen';
// import pusher sevice provider
import { PusherServiceProvider } from '../providers/pusher-service/pusher-service';
@NgModule({
declarations: [
MyApp,
HomePage,
TabsPage,
ProfilePage,
CommentPage,
CreatePostPage
],
imports: [
HttpClientModule,
ApolloModule,
HttpLinkModule,
BrowserModule,
IonicModule.forRoot(MyApp),
],
bootstrap: [IonicApp],
entryComponents: [
MyApp,
HomePage,
TabsPage,
ProfilePage,
CommentPage,
CreatePostPage
],
providers: [
StatusBar,
SplashScreen,
{ provide: ErrorHandler, useClass: IonicErrorHandler },
PusherServiceProvider
]
})
export class AppModule {
constructor(apollo: Apollo, httpLink: HttpLink) {
apollo.create({
link: httpLink.create({ uri: 'http://localhost:4466' }), // uri specifies the endpoint for our graphql server
cache: new InMemoryCache()
})
}
}
Now that the PusherServiceProvider
has been registered, we can then use it in our application to fetch posts in realtime.
Triggering and displaying posts in realtime
In the uploadPost
method of the CreatePostPage
, after a post is created, the user is shown a success alert letting them know the upload is successful. Now, update the uploadPost
method to send a POST
request to the event server before displaying the success alert:
// src/pages/create-post/create-post.ts
[...]
public uploadPost() {
this.apollo.mutate({
mutation: createUserPost,
variables: {
image_url: this.image_url,
description: this.description,
likes: 10,
postedAt: this.posted_at,
user: { "connect": { "id": this.user_id } }
}
}).subscribe((data) => {
// after sucessful upload, trigger pusher event
let post_response: any = data;
this.http.post('http://localhost:3128/trigger-post-event', post_response.data.createPost)
.subscribe(() => {
this.showAlert('Post Shared', 'Your post has been shared with other users');
this.navCtrl.push(HomePage);
});
}, (error) => {
this.showAlert('Error', 'There was an error sharing your post, please retry');
console.log('there was an error sending :the query', error);
})
}
[...]
Now that the event is being triggered, the next thing we need to do is to update the HomePage with new posts in realtime for all users. Add update your home.ts
file to include the following:
// app/src/pages/home/home.ts
// [...] other imports
import { PusherServiceProvider } from '../../providers/pusher-service/pusher-service';
// [...]
export class HomePage implements OnInit {
// [...]
post_channel: any;
constructor(
public navCtrl: NavController,
private apollo: Apollo,
private pusher: PusherServiceProvider) {
// [...]
this.initializeRealtimePosts();
}
initializeRealtimePosts() {
this.post_channel = this.pusher.postChannel();
let self = this;
this.post_channel.bind('new-post', function (data) {
let posts_copy = [data.post];
self.posts = posts_copy.concat(self.posts);
})
}
// [...]
}
Now, your HomePage is ready to display new user posts in realtime. Navigate your application in the browser (localhost:8100
) and create a new post:
Triggering and displaying comments in realtime
In the postComment
method of the CommentPage
, let’s make a request to the event server to after the comment is added to a post. Update the postComment
method in the comment.ts
as follows:
// src/page/comment/comment.ts
[...]
public postComment() {
this.apollo.mutate({
mutation: makeComment,
variables: {
message: this.user_comment,
postedAt: (new Date()).toISOString(),
user: { connect: { id: "USER_ID_FETCHED_FROM_GRAPHQL_SERVER" } },
post: { connect: { id: this.post_id } }
}
}).subscribe((data) => {
let post_response: any = data;
// after successful upload, trigger new comment event
this.http.post('http://localhost:3128/trigger-comment-event', post_response.data.createComment)
.subscribe(() => {
this.showAlert('Success', 'Comment posted successfully');
this.navCtrl.push(HomePage);
});
}, (error) => {
this.showAlert('Error', 'Error posting comment');
});
}
[...]
Note: Get a user ID for the user you want to post the comment for from the GraphQL server. In the previous article in the series, we looked at querying the data store for all users. Pick a user id you want to use.
To see the comments in realtime after they have been pushed to the comment-channel
via the web server, we create a initializeRealtimeComments
method in the CommentPage
that gets the comment-channel
from the PusherServiceProvider
. We then bind the new-comment
event to the comment-channel
. When a new-comment
event occurs, the comments on the page are the updated automatically.
Update the comment.ts
file to include the following:
// src/app/pages/comment/comment.ts
// [...] other imports
import { PusherServiceProvider } from '../../providers/pusher-service/pusher-service';
// [...]
export class CommentPage {
comments: any;
username: string;
post_desc: string;
user_avatar: string;
post_id: string;
user_comment: string = "";
comment_channel: any;
constructor(
public navCtrl: NavController, public navParams: NavParams, private apollo: Apollo, public alertCtrl: AlertController, public http: HttpClient, private pusher: PusherServiceProvider
) {
// [...]
this.initializeRealtimeComments();
}
initializeRealtimeComments() {
this.comment_channel = this.pusher.commentChannel();
let self = this;
this.comment_channel.bind('new-comment', function (data) {
let comment_copy = self.comments;
self.comments = comment_copy.concat(data.comment);;
})
}
// [...]
}
Now, when you open your browser and you navigate to localhost:8100
. Here’s what happens when you create a new comment:
You can see the application rendering new comments in realtime without any other action from other users.
Conclusion
In this part of the series, we went through in detail how upload images from multiple sources seamlessly using Cloudinary, how to make mutations to your GraphQL server using the Apollo Client and also enabling realtime functionality for posts and comments using Pusher. Here’s a link to the GitHub repository for reference. Notice that through the series, you have been viewing your application on the browser. In the next part of the series, we will walk through steps to take to testing your Ionic application on mobile devices.
2 July 2019
by Oreoluwa Ogundipe