Skip to content

Latest commit

 

History

History
509 lines (463 loc) · 18.8 KB

File metadata and controls

509 lines (463 loc) · 18.8 KB

Step 15: Addressbook integration

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:

Changed src/app/app.module.ts
@@ -9,6 +9,7 @@
 ┊ 9┊ 9┊import { SmsReceiver } from "../ionic/sms-receiver";
 ┊10┊10┊import { Camera } from '@ionic-native/camera';
 ┊11┊11┊import { Crop } from '@ionic-native/crop';
+┊  ┊12┊import { Contacts } from "@ionic-native/contacts";
 ┊12┊13┊import { AgmCoreModule } from '@agm/core';
 ┊13┊14┊import { MomentModule } from 'angular2-moment';
 ┊14┊15┊import { ChatsPage } from '../pages/chats/chats';
@@ -75,7 +76,8 @@
 ┊75┊76┊    Sim,
 ┊76┊77┊    SmsReceiver,
 ┊77┊78┊    Camera,
-┊78┊  ┊    Crop
+┊  ┊79┊    Crop,
+┊  ┊80┊    Contacts
 ┊79┊81┊  ]
 ┊80┊82┊})
 ┊81┊83┊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:

Changed tsconfig.json
@@ -5,6 +5,7 @@
 ┊ 5┊ 5┊    "declaration": false,
 ┊ 6┊ 6┊    "emitDecoratorMetadata": true,
 ┊ 7┊ 7┊    "experimentalDecorators": true,
+┊  ┊ 8┊    "downlevelIteration": true,
 ┊ 8┊ 9┊    "lib": [
 ┊ 9┊10┊      "dom",
 ┊10┊11┊      "es2015",

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

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

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

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

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

Changed api/server/main.ts
@@ -1,86 +1,9 @@
 ┊ 1┊ 1┊import { Meteor } from 'meteor/meteor';
-┊ 2┊  ┊import { Picture } from './models';
 ┊ 3┊ 2┊import { Accounts } from 'meteor/accounts-base';
-┊ 4┊  ┊import { Users } from './collections/users';
 ┊ 5┊ 3┊
 ┊ 6┊ 4┊Meteor.startup(() => {
 ┊ 7┊ 5┊  if (Meteor.settings) {
 ┊ 8┊ 6┊    Object.assign(Accounts._options, Meteor.settings['accounts-phone']);
 ┊ 9┊ 7┊    SMS.twilio = Meteor.settings['twilio'];
 ┊10┊ 8┊  }
-┊11┊  ┊
-┊12┊  ┊  if (Users.collection.find().count() > 0) {
-┊13┊  ┊    return;
-┊14┊  ┊  }
-┊15┊  ┊
-┊16┊  ┊  let picture = importPictureFromUrl({
-┊17┊  ┊    name: 'man1.jpg',
-┊18┊  ┊    url: 'https://randomuser.me/api/portraits/men/1.jpg'
-┊19┊  ┊  });
-┊20┊  ┊
-┊21┊  ┊  Accounts.createUserWithPhone({
-┊22┊  ┊    phone: '+972540000001',
-┊23┊  ┊    profile: {
-┊24┊  ┊      name: 'Ethan Gonzalez',
-┊25┊  ┊      pictureId: picture._id
-┊26┊  ┊    }
-┊27┊  ┊  });
-┊28┊  ┊
-┊29┊  ┊  picture = importPictureFromUrl({
-┊30┊  ┊    name: 'lego1.jpg',
-┊31┊  ┊    url: 'https://randomuser.me/api/portraits/lego/1.jpg'
-┊32┊  ┊  });
-┊33┊  ┊
-┊34┊  ┊  Accounts.createUserWithPhone({
-┊35┊  ┊    phone: '+972540000002',
-┊36┊  ┊    profile: {
-┊37┊  ┊      name: 'Bryan Wallace',
-┊38┊  ┊      pictureId: picture._id
-┊39┊  ┊    }
-┊40┊  ┊  });
-┊41┊  ┊
-┊42┊  ┊  picture = importPictureFromUrl({
-┊43┊  ┊    name: 'woman1.jpg',
-┊44┊  ┊    url: 'https://randomuser.me/api/portraits/women/1.jpg'
-┊45┊  ┊  });
-┊46┊  ┊
-┊47┊  ┊  Accounts.createUserWithPhone({
-┊48┊  ┊    phone: '+972540000003',
-┊49┊  ┊    profile: {
-┊50┊  ┊      name: 'Avery Stewart',
-┊51┊  ┊      pictureId: picture._id
-┊52┊  ┊    }
-┊53┊  ┊  });
-┊54┊  ┊
-┊55┊  ┊  picture = importPictureFromUrl({
-┊56┊  ┊    name: 'woman2.jpg',
-┊57┊  ┊    url: 'https://randomuser.me/api/portraits/women/2.jpg'
-┊58┊  ┊  });
-┊59┊  ┊
-┊60┊  ┊  Accounts.createUserWithPhone({
-┊61┊  ┊    phone: '+972540000004',
-┊62┊  ┊    profile: {
-┊63┊  ┊      name: 'Katie Peterson',
-┊64┊  ┊      pictureId: picture._id
-┊65┊  ┊    }
-┊66┊  ┊  });
-┊67┊  ┊
-┊68┊  ┊  picture = importPictureFromUrl({
-┊69┊  ┊    name: 'man2.jpg',
-┊70┊  ┊    url: 'https://randomuser.me/api/portraits/men/2.jpg'
-┊71┊  ┊  });
-┊72┊  ┊
-┊73┊  ┊  Accounts.createUserWithPhone({
-┊74┊  ┊    phone: '+972540000005',
-┊75┊  ┊    profile: {
-┊76┊  ┊      name: 'Ray Edwards',
-┊77┊  ┊      pictureId: picture._id
-┊78┊  ┊    }
-┊79┊  ┊  });
 ┊80┊ 9┊});
-┊81┊  ┊
-┊82┊  ┊function importPictureFromUrl(options: { name: string, url: string }): Picture {
-┊83┊  ┊  const description = { name: options.name };
-┊84┊  ┊
-┊85┊  ┊  return Meteor.call('ufsImportURL', options.url, description, 'pictures');
-┊86┊  ┊}

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:

Changed api/server/main.ts
@@ -1,9 +1,86 @@
 ┊ 1┊ 1┊import { Meteor } from 'meteor/meteor';
+┊  ┊ 2┊import { Picture } from './models';
 ┊ 2┊ 3┊import { Accounts } from 'meteor/accounts-base';
+┊  ┊ 4┊import { Users } from './collections/users';
 ┊ 3┊ 5┊
 ┊ 4┊ 6┊Meteor.startup(() => {
 ┊ 5┊ 7┊  if (Meteor.settings) {
 ┊ 6┊ 8┊    Object.assign(Accounts._options, Meteor.settings['accounts-phone']);
 ┊ 7┊ 9┊    SMS.twilio = Meteor.settings['twilio'];
 ┊ 8┊10┊  }
+┊  ┊11┊
+┊  ┊12┊  if (Users.collection.find().count() > 0) {
+┊  ┊13┊    return;
+┊  ┊14┊  }
+┊  ┊15┊
+┊  ┊16┊  let picture = importPictureFromUrl({
+┊  ┊17┊    name: 'man1.jpg',
+┊  ┊18┊    url: 'https://randomuser.me/api/portraits/men/1.jpg'
+┊  ┊19┊  });
+┊  ┊20┊
+┊  ┊21┊  Accounts.createUserWithPhone({
+┊  ┊22┊    phone: '+972540000001',
+┊  ┊23┊    profile: {
+┊  ┊24┊      name: 'Ethan Gonzalez',
+┊  ┊25┊      pictureId: picture._id
+┊  ┊26┊    }
+┊  ┊27┊  });
+┊  ┊28┊
+┊  ┊29┊  picture = importPictureFromUrl({
+┊  ┊30┊    name: 'lego1.jpg',
+┊  ┊31┊    url: 'https://randomuser.me/api/portraits/lego/1.jpg'
+┊  ┊32┊  });
+┊  ┊33┊
+┊  ┊34┊  Accounts.createUserWithPhone({
+┊  ┊35┊    phone: '+972540000002',
+┊  ┊36┊    profile: {
+┊  ┊37┊      name: 'Bryan Wallace',
+┊  ┊38┊      pictureId: picture._id
+┊  ┊39┊    }
+┊  ┊40┊  });
+┊  ┊41┊
+┊  ┊42┊  picture = importPictureFromUrl({
+┊  ┊43┊    name: 'woman1.jpg',
+┊  ┊44┊    url: 'https://randomuser.me/api/portraits/women/1.jpg'
+┊  ┊45┊  });
+┊  ┊46┊
+┊  ┊47┊  Accounts.createUserWithPhone({
+┊  ┊48┊    phone: '+972540000003',
+┊  ┊49┊    profile: {
+┊  ┊50┊      name: 'Avery Stewart',
+┊  ┊51┊      pictureId: picture._id
+┊  ┊52┊    }
+┊  ┊53┊  });
+┊  ┊54┊
+┊  ┊55┊  picture = importPictureFromUrl({
+┊  ┊56┊    name: 'woman2.jpg',
+┊  ┊57┊    url: 'https://randomuser.me/api/portraits/women/2.jpg'
+┊  ┊58┊  });
+┊  ┊59┊
+┊  ┊60┊  Accounts.createUserWithPhone({
+┊  ┊61┊    phone: '+972540000004',
+┊  ┊62┊    profile: {
+┊  ┊63┊      name: 'Katie Peterson',
+┊  ┊64┊      pictureId: picture._id
+┊  ┊65┊    }
+┊  ┊66┊  });
+┊  ┊67┊
+┊  ┊68┊  picture = importPictureFromUrl({
+┊  ┊69┊    name: 'man2.jpg',
+┊  ┊70┊    url: 'https://randomuser.me/api/portraits/men/2.jpg'
+┊  ┊71┊  });
+┊  ┊72┊
+┊  ┊73┊  Accounts.createUserWithPhone({
+┊  ┊74┊    phone: '+972540000005',
+┊  ┊75┊    profile: {
+┊  ┊76┊      name: 'Ray Edwards',
+┊  ┊77┊      pictureId: picture._id
+┊  ┊78┊    }
+┊  ┊79┊  });
 ┊ 9┊80┊});
+┊  ┊81┊
+┊  ┊82┊function importPictureFromUrl(options: { name: string, url: string }): Picture {
+┊  ┊83┊  const description = { name: options.name };
+┊  ┊84┊
+┊  ┊85┊  return Meteor.call('ufsImportURL', options.url, description, 'pictures');
+┊  ┊86┊}
Changed api/server/publications.ts
@@ -17,10 +17,18 @@
 ┊17┊17┊  if (pattern) {
 ┊18┊18┊    selector = {
 ┊19┊19┊      'profile.name': { $regex: pattern, $options: 'i' },
-┊20┊  ┊      'phone.number': {$in: contacts}
+┊  ┊20┊      $or: [
+┊  ┊21┊        {'phone.number': {$in: contacts}},
+┊  ┊22┊        {'profile.name': {$in: ['Ethan Gonzalez', 'Bryan Wallace', 'Avery Stewart', 'Katie Peterson', 'Ray Edwards']}}
+┊  ┊23┊      ]
 ┊21┊24┊    };
 ┊22┊25┊  } else {
-┊23┊  ┊    selector = {'phone.number': {$in: contacts}}
+┊  ┊26┊    selector = {
+┊  ┊27┊      $or: [
+┊  ┊28┊        {'phone.number': {$in: contacts}},
+┊  ┊29┊        {'profile.name': {$in: ['Ethan Gonzalez', 'Bryan Wallace', 'Avery Stewart', 'Katie Peterson', 'Ray Edwards']}}
+┊  ┊30┊      ]
+┊  ┊31┊    }
 ┊24┊32┊  }
 ┊25┊33┊
 ┊26┊34┊  return {
< Previous Step Next Step >