🎉 New release for Pusher Chatkit - Webhooks! Extend your in-app chat functionality
Hide
Products
chatkit_full-logo

Extensible API for in-app chat

channels_full-logo

Build scalable realtime features

beams_full-logo

Programmatic push notifications

Developers

Docs

Read the docs to learn how to use our products

Tutorials

Explore our tutorials to build apps with Pusher products

Support

Reach out to our support team for help and advice

Sign in
Sign up

Build an Instagram clone with Ionic: Part 3 - Adding data dynamically and enabling realtime functionality

  • Oreoluwa Ogundipe
July 2nd, 2019
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 {
      // [...]
    }

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 an id 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> &nbsp;&nbsp;&nbsp; {{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 Cloudinary UPLOAD_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 and CLOUDINARY_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 server
  • body-parser to handle incoming requests
  • pusher 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 and new-comment events on the post-channel and comment-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.

Clone the project repository
  • Cordova
  • CSS
  • GraphQL
  • HTML
  • JavaScript
  • Node.js
  • TypeScript
  • Channels

Products

  • Channels
  • Chatkit
  • Beams

© 2019 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.