Fork me on GitHub

WhatsApp Clone with Meteor and Ionic 2 CLI

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

Addressbook integration

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 native address book integration, to automatically show only the users whose numbers are present in our address book.

Ionic 2 is provided by default with a Cordova plug-in called cordova-plugin-contacts, which allows us to retrieve the contacts from the address book.

Let's start by installing the Contacts Cordova plug-in:

$ ionic cordova plugin add cordova-plugin-contacts --save
$ npm install --save @ionic-native/contacts

Then let's add it to app.module.ts:

15.2 Add Contacts to app.module.ts src/app/app.module.ts
9
10
11
12
13
14
15
 
76
77
78
79
80
81
82
83
import { SmsReceiver } from "../ionic/sms-receiver";
import { Camera } from '@ionic-native/camera';
import { Crop } from '@ionic-native/crop';
import { Contacts } from "@ionic-native/contacts";
import { AgmCoreModule } from '@agm/core';
import { MomentModule } from 'angular2-moment';
import { ChatsPage } from '../pages/chats/chats';
...some lines skipped...
    Sim,
    SmsReceiver,
    Camera,
    Crop,
    Contacts
  ]
})
export class AppModule {}

Since we're going to use Sets in our code, we will have to set the Typescript target to es6 or enable downlevelIteration:

15.3 We need to set downlevelIteration or target es6 in order to use Sets tsconfig.json
5
6
7
8
9
10
11
    "declaration": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "downlevelIteration": true,
    "lib": [
      "dom",
      "es2015",

Now we can create the appropriate handler in the PhoneService, we will use it inside the NewChatPage:

15.4 Implement getContactsFromAddressbook in the phone service src/services/phone.ts
3
4
5
6
7
8
9
 
13
14
15
16
17
18
19
20
 
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import { Meteor } from 'meteor/meteor';
import { Platform } from 'ionic-angular';
import { Sim } from '@ionic-native/sim';
import { Contact, ContactFieldType, Contacts, IContactField, IContactFindOptions } from "@ionic-native/contacts";
import { SmsReceiver } from "../ionic/sms-receiver";
import * as Bluebird from "bluebird";
import { TWILIO_SMS_NUMBERS } from "api/models";
...some lines skipped...
export class PhoneService {
  constructor(private platform: Platform,
              private sim: Sim,
              private smsReceiver: SmsReceiver,
              private contacts: Contacts) {
    Bluebird.promisifyAll(this.smsReceiver);
  }
 
...some lines skipped...
    }
  }
 
  getContactsFromAddressbook(): Promise<string[]> {
    const getContacts = (): Promise<Contact[]> => {
      if (!this.platform.is('cordova')) {
        return Promise.reject(new Error('Cannot get contacts: not cordova.'));
      }
 
      const fields: ContactFieldType[] = ["phoneNumbers"];
      const options: IContactFindOptions = {
        filter: "",
        multiple: true,
        desiredFields: ["phoneNumbers"],
        hasPhoneNumber: true
      };
      return this.contacts.find(fields, options);
    };
 
    const cleanPhoneNumber = (phoneNumber: string): string => {
      const phoneNumberNoSpaces: string = phoneNumber.replace(/ /g, '');
 
      if (phoneNumberNoSpaces.charAt(0) === '+') {
        return phoneNumberNoSpaces;
      } else if (phoneNumberNoSpaces.substring(0, 2) === "00") {
        return '+' + phoneNumberNoSpaces.slice(2);
      } else {
        // Use user's international prefix when absent
        // FIXME: update meteor-accounts-phone typings
        const prefix: string = (<any>Meteor.user()).phone.number.substring(0, 3);
 
        return prefix + phoneNumberNoSpaces;
      }
    };
 
    return new Promise((resolve, reject) => {
      getContacts()
        .then((contacts: Contact[]) => {
          const arrayOfArrays: string[][] = contacts
            .map((contact: Contact) => {
              return contact.phoneNumbers
                .filter((phoneNumber: IContactField) => {
                  return phoneNumber.type === "mobile";
                }).map((phoneNumber: IContactField) => {
                  return cleanPhoneNumber(phoneNumber.value);
                }).filter((phoneNumber: string) => {
                  return phoneNumber.slice(1).match(/^[0-9]+$/) && phoneNumber.length >= 8;
                });
            });
          const flattenedArray: string[] = [].concat(...arrayOfArrays);
          const uniqueArray: string[] = [...new Set(flattenedArray)];
          resolve(uniqueArray);
        })
        .catch((e: Error) => {
          reject(e);
        });
    });
  }
 
  verify(phoneNumber: string): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      Accounts.requestPhoneVerification(phoneNumber, (e: Error) => {
15.5 Use getContactsFromAddressbook in new-chat.ts src/pages/chats/new-chat.ts
5
6
7
8
9
10
11
 
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 
31
32
33
34
35
36
37
38
39
40
41
42
43
 
53
54
55
56
57
58
59
60
61
 
74
75
76
77
78
79
80
import { MeteorObservable } from 'meteor-rxjs';
import * as _ from 'lodash';
import { Observable, Subscription, BehaviorSubject } from 'rxjs';
import { PhoneService } from "../../services/phone";
 
@Component({
  selector: 'new-chat',
...some lines skipped...
  senderId: string;
  users: Observable<User[]>;
  usersSubscription: Subscription;
  contacts: string[] = [];
  contactsPromise: Promise<void>;
 
  constructor(
    private alertCtrl: AlertController,
    private viewCtrl: ViewController,
    private platform: Platform,
    private phoneService: PhoneService
  ) {
    this.senderId = Meteor.userId();
    this.searchPattern = new BehaviorSubject(undefined);
...some lines skipped...
 
  ngOnInit() {
    this.observeSearchBar();
    this.contactsPromise = this.phoneService.getContactsFromAddressbook()
      .then((phoneNumbers: string[]) => {
        this.contacts = phoneNumbers;
      })
      .catch((e: Error) => {
        console.error(e.message);
      });
  }
 
  updateSubscription(newValue) {
...some lines skipped...
          this.usersSubscription.unsubscribe();
        }
 
        this.contactsPromise.then(() => {
          this.usersSubscription = this.subscribeUsers();
        });
      });
  }
 
...some lines skipped...
 
  subscribeUsers(): Subscription {
    // Fetch all users matching search pattern
    const subscription = MeteorObservable.subscribe('users', this.searchPattern.getValue(), this.contacts);
    const autorun = MeteorObservable.autorun();
 
    return Observable.merge(subscription, autorun).subscribe(() => {

We will have to update the users publication to filter our results:

15.6 Update users publication to handle addressbook contacts api/server/publications.ts
5
6
7
8
9
10
11
12
 
16
17
18
19
20
21
22
23
24
25
26
import { Pictures } from './collections/pictures';
 
Meteor.publishComposite('users', function(
  pattern: string,
  contacts: string[]
): PublishCompositeConfig<User> {
  if (!this.userId) {
    return;
...some lines skipped...
 
  if (pattern) {
    selector = {
      'profile.name': { $regex: pattern, $options: 'i' },
      'phone.number': {$in: contacts}
    };
  } else {
    selector = {'phone.number': {$in: contacts}}
  }
 
  return {

Since they are now useless, we can finally remove our fake users from the db initialization:

15.7 Removing db initialization in main.ts api/server/main.ts
1
2
3
4
5
6
7
8
9
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
 
Meteor.startup(() => {
  if (Meteor.settings) {
    Object.assign(Accounts._options, Meteor.settings['accounts-phone']);
    SMS.twilio = Meteor.settings['twilio'];
  }
});

Obviously we will have to reset the database to see any effect:

$ npm run api:reset

To test if everything works properly I suggest to create a test user on your PC using a phone number which is already present in your phone's address book.

Let's re-add our fake users and whitelist them in the users publication for the moment:

15.8 Re-add fake users and whitelist them in the publication 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
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
import { Meteor } from 'meteor/meteor';
import { Picture } from './models';
import { Accounts } from 'meteor/accounts-base';
import { Users } from './collections/users';
 
Meteor.startup(() => {
  if (Meteor.settings) {
    Object.assign(Accounts._options, Meteor.settings['accounts-phone']);
    SMS.twilio = Meteor.settings['twilio'];
  }
 
  if (Users.collection.find().count() > 0) {
    return;
  }
 
  let picture = importPictureFromUrl({
    name: 'man1.jpg',
    url: 'https://randomuser.me/api/portraits/men/1.jpg'
  });
 
  Accounts.createUserWithPhone({
    phone: '+972540000001',
    profile: {
      name: 'Ethan Gonzalez',
      pictureId: picture._id
    }
  });
 
  picture = importPictureFromUrl({
    name: 'lego1.jpg',
    url: 'https://randomuser.me/api/portraits/lego/1.jpg'
  });
 
  Accounts.createUserWithPhone({
    phone: '+972540000002',
    profile: {
      name: 'Bryan Wallace',
      pictureId: picture._id
    }
  });
 
  picture = importPictureFromUrl({
    name: 'woman1.jpg',
    url: 'https://randomuser.me/api/portraits/women/1.jpg'
  });
 
  Accounts.createUserWithPhone({
    phone: '+972540000003',
    profile: {
      name: 'Avery Stewart',
      pictureId: picture._id
    }
  });
 
  picture = importPictureFromUrl({
    name: 'woman2.jpg',
    url: 'https://randomuser.me/api/portraits/women/2.jpg'
  });
 
  Accounts.createUserWithPhone({
    phone: '+972540000004',
    profile: {
      name: 'Katie Peterson',
      pictureId: picture._id
    }
  });
 
  picture = importPictureFromUrl({
    name: 'man2.jpg',
    url: 'https://randomuser.me/api/portraits/men/2.jpg'
  });
 
  Accounts.createUserWithPhone({
    phone: '+972540000005',
    profile: {
      name: 'Ray Edwards',
      pictureId: picture._id
    }
  });
});
 
function importPictureFromUrl(options: { name: string, url: string }): Picture {
  const description = { name: options.name };
 
  return Meteor.call('ufsImportURL', options.url, description, 'pictures');
}
15.8 Re-add fake users and whitelist them in the publication api/server/publications.ts
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
  if (pattern) {
    selector = {
      'profile.name': { $regex: pattern, $options: 'i' },
      $or: [
        {'phone.number': {$in: contacts}},
        {'profile.name': {$in: ['Ethan Gonzalez', 'Bryan Wallace', 'Avery Stewart', 'Katie Peterson', 'Ray Edwards']}}
      ]
    };
  } else {
    selector = {
      $or: [
        {'phone.number': {$in: contacts}},
        {'profile.name': {$in: ['Ethan Gonzalez', 'Bryan Wallace', 'Avery Stewart', 'Katie Peterson', 'Ray Edwards']}}
      ]
    }
  }
 
  return {