Fork me on GitHub

WhatsApp Clone with Meteor and Ionic 2 CLI

Ionic 3 Version (Last Update: 2017-06-15)

Facebook authentication

Note: If you skipped ahead to this section, click here to download a zip of the tutorial at this point.

In this step we are going to implement Facebook auth and allow our users to start new chats with their Facebook friends who already use our app.

First we will have to install a couple of Meteor packages:

api$ meteor add btafel:accounts-facebook-cordova
api$ meteor add service-configuration

Then we will need to add the Cordova plugin cordova-plugin-facebook4:

$ ionic cordova plugin add git+https://github.com/darkbasic/cordova-plugin-facebook4.git --save

Now we need to configure oauth services using service-configuration:

17.3 Configure oauth services using service-configuration api/server/main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
declare const ServiceConfiguration: any;
 
Meteor.startup(() => {
  if (Meteor.settings) {
    Object.assign(Accounts._options, Meteor.settings['accounts-phone']);
    SMS.twilio = Meteor.settings['twilio'];
  }
 
  // Configuring oAuth services
  const services = Meteor.settings.private.oAuth;
 
  if (services) {
    for (let service in services) {
      ServiceConfiguration.configurations.upsert({service: service}, {
        $set: services[service]
      });
    }
  }
});

And store credentials in settings.json:

17.4 Store credentials in settings.json api/private/settings.json
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    "adminPhoneNumbers": ["+9721234567", "+97212345678", "+97212345679"],
    "phoneVerificationMasterCode": "1234"
  },
  "public": {
    "facebook": {
      "permissions": [
        "public_profile",
        "user_friends",
        "email"
      ],
      "profileFields": [
        "name",
        "gender",
        "location"
      ]
    }
  },
  "private": {
    "fcm": {
      "key": "AIzaSyBnmvN5WNv3rAaLra1RUr9vA5k0pNp0KuY"
    },
    "oAuth": {
      "facebook": {
        "appId": "1800004730327605",
        "secret": "57f57a93e8847896a0b779c0d0cdfa7b"
      }
    }
  }
}

Since accounts-facebook-cordova pollutes our user profile on Cordova, let's filter it in our ProfilePage:

17.5 Filter user profile src/pages/profile/profile.ts
22
23
24
25
26
27
28
29
30
31
  ) {}
 
  ngOnInit(): void {
    this.profile = (({name = '', pictureId} = {}) => ({
      name,
      pictureId
    }))(Meteor.user().profile);
 
    MeteorObservable.subscribe('user').subscribe(() => {
      let platform = this.platform.is('android') ? "android" :

Now we can create a test login method to check if everything works so far:

17.6 Create a test login method and bind it to the user interface src/pages/login/login.html
22
23
24
25
26
27
28
29
30
31
  <ion-item>
    <ion-input [(ngModel)]="phone" (keypress)="onInputKeypress($event)" type="tel" placeholder="Your phone number"></ion-input>
  </ion-item>
 
  <ion-item>
    <ion-buttons>
      <button ion-button (click)="loginFacebook()">Login with Facebook</button>
    </ion-buttons>
  </ion-item>
</ion-content>
17.6 Create a test login method and bind it to the user interface src/pages/login/login.ts
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
    alert.present();
  }
 
  loginFacebook(): void {
    const options = {
      requestPermissions: ['public_profile', 'user_friends', 'email']
    };
 
    (<any>Meteor).loginWithFacebook(options, (error: Error) => {
      if (error) {
        this.handleError(error);
      } else {
        console.log("Logged in with Facebook succesfully.");
        console.log(Meteor.user());
      }
    });
  }
 
  handleLogin(alert: Alert): void {
    alert.dismiss().then(() => {
      return this.phoneService.verify(this.phone);

We will need to pass every connection through Nginx:

17.7 Let every connection pass through Nginx meteor-client.config.json
1
2
3
4
5
6
7
{
  "runtime": {
    "DDP_DEFAULT_CONNECTION_URL": "http://meteor.linuxsystems.it",
    "ROOT_URL": "http://meteor.linuxsystems.it"
  },
  "import": [
 
17.7 Let every connection pass through Nginx package.json
9
10
11
12
13
14
15
16
    "url": "https://github.com/Urigo/Ionic2CLI-Meteor-WhatsApp.git"
  },
  "scripts": {
    "api": "cd api && export ROOT_URL=http://meteor.linuxsystems.it && meteor run --settings private/settings.json",
    "api:reset": "cd api && export ROOT_URL=http://meteor.linuxsystems.it && meteor reset",
    "clean": "ionic-app-scripts clean",
    "build": "ionic-app-scripts build",
    "lint": "ionic-app-scripts lint",

This is the core of our Nginx config:

server {
  listen 80;
  server_name meteor.linuxsystems.it;

  location / {
    proxy_pass http://meteor.linuxsystems.it:8100;
  }

  location ~ ^/(_oauth|packages|ufs) {
    proxy_pass http://meteor.linuxsystems.it:3000;
  }

  location /sockjs {
    proxy_pass http://meteor.linuxsystems.it:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
  }

  error_page  500 502 503 504  /50x.html;

  location = /50x.html {
    root /usr/share/nginx/html;
  }
}

Now that we know that everything works we can remove our login test code:

17.8 Remove the login test code src/pages/login/login.html
22
23
24
25
  <ion-item>
    <ion-input [(ngModel)]="phone" (keypress)="onInputKeypress($event)" type="tel" placeholder="Your phone number"></ion-input>
  </ion-item>
</ion-content>
17.8 Remove the login test code src/pages/login/login.ts
50
51
52
53
54
55
    alert.present();
  }
 
  handleLogin(alert: Alert): void {
    alert.dismiss().then(() => {
      return this.phoneService.verify(this.phone);

Since we need to link our users to their Facebook accounts instead of creating brand new accounts, let's add the darkbasic:link-accounts Meteor package:

api$ meteor add darkbasic:link-accounts

Now we create the linkFacebook method in the phone service:

17.10 Create linkFacebook method in phone service src/services/phone.ts
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
    });
  }
 
  linkFacebook(): Promise<any> {
    return new Promise((resolve, reject) => {
      const options = {
        requestPermissions: ['public_profile', 'user_friends', 'email']
      };
 
      // TODO: add link-accounts types to meteor typings
      (<any>Meteor).linkWithFacebook(options, (error: Error) => {
        if (error) {
          reject(new Error(error.message));
        } else {
          resolve();
        }
      });
    });
  }
 
  logout(): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      Meteor.logout((e: Error) => {

And FacebookPage with its view and style sheet:

17.11 Create FacebookPage src/pages/login/facebook.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import { Component } from "@angular/core";
import { Alert, AlertController, NavController } from "ionic-angular";
import { PhoneService } from "../../services/phone";
import { ProfilePage } from "../profile/profile";
 
@Component({
  selector: 'facebook',
  templateUrl: 'facebook.html'
})
export class FacebookPage {
 
  constructor(private alertCtrl: AlertController,
              private phoneService: PhoneService,
              private navCtrl: NavController) {
  }
 
  cancel(): void {
    const alert: Alert = this.alertCtrl.create({
      title: 'Confirm',
      message: `Would you like to proceed without linking your account with Facebook?`,
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel'
        },
        {
          text: 'Yes',
          handler: () => {
            this.dontLink(alert);
            return false;
          }
        }
      ]
    });
 
    alert.present();
  }
 
  linkFacebook(): void {
    this.phoneService.linkFacebook()
      .then(() => {
        this.navCtrl.setRoot(ProfilePage, {}, {
          animate: true
        });
      })
      .catch((e) => {
        this.handleError(e);
      });
  }
 
  dontLink(alert: Alert): void {
    alert.dismiss()
      .then(() => {
        this.navCtrl.setRoot(ProfilePage, {}, {
          animate: true
        });
      })
      .catch((e) => {
        this.handleError(e);
      });
  }
 
  handleError(e: Error): void {
    console.error(e);
 
    const alert = this.alertCtrl.create({
      title: 'Oops!',
      message: e.message,
      buttons: ['OK']
    });
 
    alert.present();
  }
}
17.12 Create FacebookPage View src/pages/login/facebook.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<ion-header>
  <ion-navbar color="whatsapp">
    <ion-title>Link with Facebook</ion-title>
 
    <ion-buttons end>
      <button ion-button class="done-button" (click)="cancel()">Cancel</button>
    </ion-buttons>
  </ion-navbar>
</ion-header>
 
<ion-content padding class="login-page-content">
  <div class="instructions">
    <div>
      You can link your account with Facebook to chat with more friends.
    </div>
    <br>
    <ion-item>
      <ion-buttons>
        <button ion-button (click)="linkFacebook()">Login with Facebook</button>
      </ion-buttons>
    </ion-item>
  </div>
</ion-content>
17.13 Create FacebookPage style sheet src/pages/login/facebook.scss
1
2
3
4
5
6
7
8
9
10
11
.login-page-content {
  .instructions {
    text-align: center;
    font-size: medium;
    margin: 50px;
  }
 
  .text-input {
    text-align: center;
  }
}

Let's add it to app.module.ts:

17.14 Add FacebookPage to app.module.ts src/app/app.module.ts
23
24
25
26
27
28
29
 
37
38
39
40
41
42
43
 
61
62
63
64
65
66
67
import { NewLocationMessageComponent } from '../pages/messages/location-message';
import { ShowPictureComponent } from '../pages/messages/show-picture';
import { ProfilePage } from '../pages/profile/profile';
import { FacebookPage } from "../pages/login/facebook";
import { VerificationPage } from '../pages/verification/verification';
import { PhoneService } from '../services/phone';
import { PictureService } from '../services/picture';
...some lines skipped...
    LoginPage,
    VerificationPage,
    ProfilePage,
    FacebookPage,
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent,
...some lines skipped...
    LoginPage,
    VerificationPage,
    ProfilePage,
    FacebookPage,
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent,

Now we can finally redirect to FacebookPage from VerificationPage and the Facebook account linking should be finally working:

17.15 Redirect to FacebookPage from the VerificationPage src/pages/verification/verification.ts
1
2
3
4
5
6
7
 
43
44
45
46
47
48
49
import { AfterContentInit, Component, OnInit } from '@angular/core';
import { AlertController, NavController, NavParams } from 'ionic-angular';
import { PhoneService } from '../../services/phone';
import { FacebookPage } from "../login/facebook";
 
@Component({
  selector: 'verification',
...some lines skipped...
 
  verify(): void {
    this.phoneService.login(this.phone, this.code).then(() => {
      this.navCtrl.setRoot(FacebookPage, {}, {
        animate: true
      });
    })

It's time to fetch our name and profile picture from Facebook, as well as listing our Facebook friends who we want to chat with.

Let's start by adding the fb package:

$ npm install --save fb

Now we can create our server side Facebook service:

17.17 Create facebook Meteor service api/server/services/facebook.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
import {Users} from "../collections/users";
import {FB} from "fb";
 
export interface FbProfile {
  name?: string;
  pictureUrl?: string;
};
 
export class FacebookService {
  private APP_ID: string = Meteor.settings.private.oAuth.facebook.appId;
  private APP_SECRET: string = Meteor.settings.private.oAuth.facebook.secret;
 
  constructor() {
  }
 
  async getAppToken(): Promise<string> {
    try {
      return (await FB.api(`/oauth/access_token?client_id=${this.APP_ID}&client_secret=${this.APP_SECRET}&grant_type=client_credentials`)).access_token;
    } catch (e) {
      throw new Meteor.Error('cannot-receive', 'Cannot get an app token');
    }
  }
 
//TODO: create a before.insert in the users collection to check if the token is valid
  async tokenIsValid(token: string): Promise<boolean> {
    try {
      return (await FB.api(`debug_token?input_token=${token}&access_token=${await this.getAppToken()}`)).data.is_valid;
    } catch (e) {
      console.error(e);
      return false;
    }
  }
 
// Useless because we already got a long lived token
  async getLongLivedToken(token: string): Promise<string> {
    try {
      return (await FB.api(`/oauth/access_token?grant_type=fb_exchange_token&client_id=${this.APP_ID}&client_secret=${this.APP_SECRET}&fb_exchange_token=${token}`)).access_token;
    } catch (e) {
      throw new Meteor.Error('cannot-receive', 'Cannot get a long lived token');
    }
  }
 
  async getAccessToken(user: string): Promise<string> {
    //TODO: check if token has expired, if so the user must login again
    try {
      const facebook = await Users.findOne(user).services.facebook;
      if (facebook.accessToken) {
        return facebook.accessToken;
      } else {
        throw new Error();
      }
    } catch (e) {
      throw new Meteor.Error('unauthorized', 'User must be logged-in with Facebook to call this method');
    }
  }
 
  async getFriends(accessToken: string, user?: string): Promise<any> {
    //TODO: check if more permissions are needed, if so user must login again
    try {
      const params: any = {
        //fields: 'id,name',
        limit: 5000
      };
      let friends: string[] = [];
      let result: any;
      const fb = FB.withAccessToken(accessToken);
 
      do {
        result = await fb.api(`/${user || 'me'}/friends`, params);
        friends = friends.concat(result.data);
        params.after = result.paging && result.paging.cursors && result.paging.cursors.after;
      } while (result.paging && result.paging.next);
 
      return friends;
    } catch (e) {
      console.error(e);
      throw new Meteor.Error('cannot-receive', 'Cannot get friends')
    }
  }
 
  async getProfile(accessToken: string, user?: string): Promise<FbProfile> {
    //TODO: check if more permissions are needed, if so user must login again
    try {
      const params: any = {
        fields: 'id,name,picture.width(800).height(800)'
      };
 
      let profile: FbProfile = {};
 
      const fb = FB.withAccessToken(accessToken);
      const result = await fb.api(`/${user || 'me'}`, params);
 
      profile.name = result.name;
      profile.pictureUrl = result.picture.data.url;
 
      return profile;
    } catch (e) {
      console.error(e);
      throw new Meteor.Error('cannot-receive', 'Cannot get profile')
    }
  }
}
 
export const facebookService = new FacebookService();

And the getFbProfile Meteor method:

17.18 Create getFbProfile Meteor method api/server/methods.ts
4
5
6
7
8
9
10
 
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
import { check, Match } from 'meteor/check';
import { Users } from "./collections/users";
import { fcmService } from "./services/fcm";
import { facebookService, FbProfile } from "./services/facebook";
 
const nonEmptyString = Match.Where((str) => {
  check(str, String);
...some lines skipped...
    check(token, nonEmptyString);
 
    Users.collection.update({_id: this.userId}, {$set: {"fcmToken": token}});
  },
  async getFbProfile(): Promise<FbProfile> {
    if (!this.userId) throw new Meteor.Error('unauthorized', 'User must be logged-in to call this method');
 
    if (!Users.collection.findOne({'_id': this.userId}).services.facebook) {
      throw new Meteor.Error('unauthorized', 'User must be logged-in with Facebook to call this method');
    }
 
    //TODO: handle error: token may be expired
    const accessToken = await facebookService.getAccessToken(this.userId);
    //TODO: handle error: user may have denied permissions
    return await facebookService.getProfile(accessToken);
  }
});

Finally we can update the FacebookPage to set the name and the picture from Facebook:

17.19 Update facebook.ts to set name and picture from Facebook src/pages/login/facebook.ts
2
3
4
5
6
7
8
9
10
 
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import { Alert, AlertController, NavController } from "ionic-angular";
import { PhoneService } from "../../services/phone";
import { ProfilePage } from "../profile/profile";
import { MeteorObservable } from "meteor-rxjs";
import { FbProfile } from "api/services/facebook";
import { Profile } from "api/models";
 
@Component({
  selector: 'facebook',
...some lines skipped...
  linkFacebook(): void {
    this.phoneService.linkFacebook()
      .then(() => {
        MeteorObservable.call('getFbProfile').subscribe({
          next: (fbProfile: FbProfile) => {
            const pathname = (new URL(fbProfile.pictureUrl)).pathname;
            const filename = pathname.substring(pathname.lastIndexOf('/') + 1);
            const description = {name: filename};
            let profile: Profile = {name: fbProfile.name, pictureId: ""};
            MeteorObservable.call('ufsImportURL', fbProfile.pictureUrl, description, 'pictures')
              .map((value) => profile.pictureId = (<any>value)._id)
              .switchMapTo(MeteorObservable.call('updateProfile', profile))
              .subscribe({
                next: () => {
                  this.navCtrl.setRoot(ProfilePage, {}, {
                    animate: true
                  });
                },
                error: (e: Error) => {
                  this.handleError(e);
                }
              });
          },
          error: (e: Error) => {
            this.handleError(e);
          }
        });
      })
      .catch((e) => {

To use promises inside publications we will install the promise Meteor package:

api$ meteor add promise

Now we can update the users publication to also publish Facebook friends:

17.21 Update users publication to publish Facebook friends api/server/publications.ts
3
4
5
6
7
8
9
 
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { Messages } from './collections/messages';
import { Chats } from './collections/chats';
import { Pictures } from './collections/pictures';
import { facebookService } from "./services/facebook";
 
Meteor.publishComposite('users', function(
  pattern: string,
...some lines skipped...
 
  let selector = {};
 
  var facebookFriendsIds: string[] = [];
  if (Users.collection.findOne({'_id': this.userId}).services.facebook) {
    //FIXME: add definitions for the promise Meteor package
    //TODO: handle error: token may be expired
    const accessToken = (<any>Promise).await(facebookService.getAccessToken(this.userId));
    //TODO: handle error: user may have denied permissions
    const facebookFriends = (<any>Promise).await(facebookService.getFriends(accessToken));
    facebookFriendsIds = facebookFriends.map((friend) => friend.id);
  }
 
  if (pattern) {
    selector = {
      'profile.name': { $regex: pattern, $options: 'i' },
      $or: [
        {'phone.number': {$in: contacts}},
        {'services.facebook.id': {$in: facebookFriendsIds}}
      ]
    };
  } else {
    selector = {
      $or: [
        {'phone.number': {$in: contacts}},
        {'services.facebook.id': {$in: facebookFriendsIds}}
      ]
    }
  }
 
  return {