Fork me on GitHub

WhatsApp Clone with Meteor and Ionic 2 CLI

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

File Upload & Images

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

In this step, we will be using Ionic 2 to pick up some images from our device's gallery, and we will use them to send pictures, and to set our profile picture.

Image Picker

First, we will a Cordova plug-in which will give us the ability to access the gallery:

$ ionic cordova plugin add git+https://github.com/dhavalsoni2001/ImagePicker.git --save
$ npm install --save @ionic-native/image-picker

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

13.2 Add Image Picker to app.module.ts src/app/app.module.ts
4
5
6
7
8
9
10
 
61
62
63
64
65
66
67
68
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
import { Geolocation } from '@ionic-native/geolocation';
import { ImagePicker } from '@ionic-native/image-picker';
import { AgmCoreModule } from '@agm/core';
import { MomentModule } from 'angular2-moment';
import { ChatsPage } from '../pages/chats/chats';
...some lines skipped...
    SplashScreen,
    Geolocation,
    {provide: ErrorHandler, useClass: IonicErrorHandler},
    PhoneService,
    ImagePicker
  ]
})
export class AppModule {}

Meteor FS

Up next, would be adding the ability to store some files in our data-base. This requires us to add 2 Meteor packages, called ufs and ufs-gridfs (Which adds support for GridFS operations. See reference), which will take care of FS operations:

api$ meteor add jalik:ufs
api$ meteor add jalik:ufs-gridfs

Since there are no declarations available for jalik:ufs, let's create a fake one to at least get rid of the warnings:

13.4 Add declarations for meteor/jalik src/declarations.d.ts
8
9
10
11
  For more info on type definition files, check out the Typescript docs here:
  https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html
*/
declare module 'meteor/jalik:ufs';

And be sure to re-bundle the Meteor client whenever you make changes in the server:

$ npm run meteor-client:bundle

Client Side

Before we proceed to the server, we will add the ability to select and upload pictures in the client. All our picture-related operations will be defined in a single service called PictureService; The first bit of this service would be picture-selection. The UploadFS package already supports that feature, but only for the browser, therefore we will be using the Cordova plug-in we've just installed to select some pictures from our mobile device:

13.5 Create PictureService with utils for files src/services/picture.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
import { Injectable } from '@angular/core';
import { Platform } from 'ionic-angular';
import { ImagePicker } from '@ionic-native/image-picker';
import { UploadFS } from 'meteor/jalik:ufs';
 
@Injectable()
export class PictureService {
  constructor(private platform: Platform,
              private imagePicker: ImagePicker) {
  }
 
  select(): Promise<File> {
    if (!this.platform.is('cordova') || !this.platform.is('mobile')) {
      return new Promise((resolve, reject) => {
        try {
          UploadFS.selectFile((file: File) => {
            resolve(file);
          });
        }
        catch (e) {
          reject(e);
        }
      });
    }
 
    return this.imagePicker.getPictures({maximumImagesCount: 1}).then((URL: string) => {
      return this.convertURLtoBlob(URL);
    });
  }
 
  convertURLtoBlob(url: string, options = {}): Promise<File> {
    return new Promise((resolve, reject) => {
      const image = document.createElement('img');
 
      image.onload = () => {
        try {
          const dataURI = this.convertImageToDataURI(image, options);
          const blob = this.convertDataURIToBlob(dataURI);
          const pathname = (new URL(url)).pathname;
          const filename = pathname.substring(pathname.lastIndexOf('/') + 1);
          const file = new File([blob], filename);
 
          resolve(file);
        }
        catch (e) {
          reject(e);
        }
      };
 
      image.src = url;
    });
  }
 
  convertImageToDataURI(image: HTMLImageElement, {MAX_WIDTH = 400, MAX_HEIGHT = 400} = {}): string {
    // Create an empty canvas element
    const canvas = document.createElement('canvas');
 
    var width = image.width, height = image.height;
 
    if (width > height) {
      if (width > MAX_WIDTH) {
        height *= MAX_WIDTH / width;
        width = MAX_WIDTH;
      }
    } else {
      if (height > MAX_HEIGHT) {
        width *= MAX_HEIGHT / height;
        height = MAX_HEIGHT;
      }
    }
 
    canvas.width = width;
    canvas.height = height;
 
    // Copy the image contents to the canvas
    const context = canvas.getContext('2d');
    context.drawImage(image, 0, 0, width, height);
 
    // Get the data-URL formatted image
    // Firefox supports PNG and JPEG. You could check image.src to
    // guess the original format, but be aware the using 'image/jpg'
    // will re-encode the image.
    const dataURL = canvas.toDataURL('image/png');
 
    return dataURL.replace(/^data:image\/(png|jpg);base64,/, '');
  }
 
  convertDataURIToBlob(dataURI): Blob {
    const binary = atob(dataURI);
 
    // Write the bytes of the string to a typed array
    const charCodes = Object.keys(binary)
      .map<number>(Number)
      .map<number>(binary.charCodeAt.bind(binary));
 
    // Build blob with typed array
    return new Blob([new Uint8Array(charCodes)], {type: 'image/jpeg'});
  }
}

In order to use the service we will need to import it in the app's NgModule as a provider:

13.6 Import PictureService src/app/app.module.ts
18
19
20
21
22
23
24
 
63
64
65
66
67
68
69
70
import { ProfilePage } from '../pages/profile/profile';
import { VerificationPage } from '../pages/verification/verification';
import { PhoneService } from '../services/phone';
import { PictureService } from '../services/picture';
import { MyApp } from './app.component';
 
@NgModule({
...some lines skipped...
    Geolocation,
    {provide: ErrorHandler, useClass: IonicErrorHandler},
    PhoneService,
    ImagePicker,
    PictureService
  ]
})
export class AppModule {}

Since now we will be sending pictures, we will need to update the message schema to support picture typed messages:

13.7 Added picture message type api/server/models.ts
7
8
9
10
11
12
13
14
 
export enum MessageType {
  TEXT = <any>'text',
  LOCATION = <any>'location',
  PICTURE = <any>'picture'
}
 
export interface Chat {

In the attachments menu, we will add a new handler for sending pictures, called sendPicture:

13.8 Implement sendPicture method src/pages/messages/messages-attachments.ts
2
3
4
5
6
7
8
 
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { ModalController, ViewController } from 'ionic-angular';
import { NewLocationMessageComponent } from './location-message';
import { MessageType } from 'api/models';
import { PictureService } from '../../services/picture';
 
@Component({
  selector: 'messages-attachments',
...some lines skipped...
export class MessagesAttachmentsComponent {
  constructor(
    private viewCtrl: ViewController,
    private modelCtrl: ModalController,
    private pictureService: PictureService
  ) {}
 
  sendPicture(): void {
    this.pictureService.select().then((file: File) => {
      this.viewCtrl.dismiss({
        messageType: MessageType.PICTURE,
        selectedPicture: file
      });
    });
  }
 
  sendLocation(): void {
    const locationModal = this.modelCtrl.create(NewLocationMessageComponent);
    locationModal.onDidDismiss((location) => {

And we will bind that handler to the view, so whenever we press the right button, the handler will be invoked with the selected picture:

13.9 Bind click event for sendPicture src/pages/messages/messages-attachments.html
1
2
3
4
5
6
<ion-content class="messages-attachments-page-content">
  <ion-list class="attachments">
    <button ion-item class="attachment attachment-gallery" (click)="sendPicture()">
      <ion-icon name="images" class="attachment-icon"></ion-icon>
      <div class="attachment-name">Gallery</div>
    </button>

Now we will be extending the MessagesPage, by adding a method which will send the picture selected in the attachments menu:

13.10 Implement the actual send of picture message src/pages/messages/messages.ts
8
9
10
11
12
13
14
 
30
31
32
33
34
35
36
37
 
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
import { MessagesOptionsComponent } from './messages-options';
import { Subscription, Observable, Subscriber } from 'rxjs';
import { MessagesAttachmentsComponent } from './messages-attachments';
import { PictureService } from '../../services/picture';
 
@Component({
  selector: 'messages-page',
...some lines skipped...
  constructor(
    navParams: NavParams,
    private el: ElementRef,
    private popoverCtrl: PopoverController,
    private pictureService: PictureService
  ) {
    this.selectedChat = <Chat>navParams.get('chat');
    this.title = this.selectedChat.title;
...some lines skipped...
          const location = params.selectedLocation;
          this.sendLocationMessage(location);
        }
        else if (params.messageType === MessageType.PICTURE) {
          const blob: File = params.selectedPicture;
          this.sendPictureMessage(blob);
        }
      }
    });
 
    popover.present();
  }
 
  sendPictureMessage(blob: File): void {
    this.pictureService.upload(blob).then((picture) => {
      MeteorObservable.call('addMessage', MessageType.PICTURE,
        this.selectedChat._id,
        picture.url
      ).zone().subscribe();
    });
  }
 
  getLocation(locationString: string): Location {
    const splitted = locationString.split(',').map(Number);
 

For now, we will add a stub for the upload method in the PictureService and we will get back to it once we finish implementing the necessary logic in the server for storing a picture:

13.11 Create stub method for upload method src/services/picture.ts
28
29
30
31
32
33
34
35
36
37
    });
  }
 
  upload(blob: File): Promise<any> {
    return Promise.resolve();
  }
 
  convertURLtoBlob(url: string, options = {}): Promise<File> {
    return new Promise((resolve, reject) => {
      const image = document.createElement('img');

Server Side

So as we said, need to handle storage of pictures that were sent by the client. First, we will create a Picture model so the compiler can recognize a picture object:

13.12 Create Picture model api/server/models.ts
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
  lng: number;
  zoom: number;
}
 
export interface Picture {
  _id?: string;
  complete?: boolean;
  extension?: string;
  name?: string;
  progress?: number;
  size?: number;
  store?: string;
  token?: string;
  type?: string;
  uploadedAt?: Date;
  uploading?: boolean;
  url?: string;
  userId?: string;
}

If you're familiar with Whatsapp, you'll know that sent pictures are compressed. That's so the data-base can store more pictures, and the traffic in the network will be faster. To compress the sent pictures, we will be using an NPM package called sharp, which is a utility library which will help us perform transformations on pictures:

$ npm install --save sharp

We used to use meteor npm and not npm because we wanted to make sure that sharp is compatible with the server. Unfortunately it recently started to break Ionic: Error: Cannot find module '@angular/tsc-wrapped/src/tsc'. If you use a node version compatible with our Meteor version (node 1.8 should be compatible with Meteor 1.6) then everything will be fine, otherwise it will break sharp.

We also used to run the previous command from the api directory, otherwise Meteor would have compiled sharp for the system Meteor version and not the project one. Since we cannot use meteor npm anymore let's stick to the root directory instead.

Since sharp bundles a binary version of libvips, depending on your distro you may need to install a packaged version of vips in order to get it working. For example on Arch Linux you will need to install vips from AUR.

Now we will create a picture store which will compress pictures using sharp right before they are inserted into the data-base:

13.14 Create pictures store api/server/collections/pictures.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
import { MongoObservable } from 'meteor-rxjs';
import { UploadFS } from 'meteor/jalik:ufs';
import { Meteor } from 'meteor/meteor';
import * as sharp from 'sharp';
import { Picture, DEFAULT_PICTURE_URL } from '../models';
 
export interface PicturesCollection<T> extends MongoObservable.Collection<T> {
  getPictureUrl(selector?: Object | string, platform?: string): string;
}
 
export const Pictures =
  new MongoObservable.Collection<Picture>('pictures') as PicturesCollection<Picture>;
 
export const PicturesStore = new UploadFS.store.GridFS({
  collection: Pictures.collection,
  name: 'pictures',
  filter: new UploadFS.Filter({
    contentTypes: ['image/*']
  }),
  permissions: new UploadFS.StorePermissions({
    insert: picturesPermissions,
    update: picturesPermissions,
    remove: picturesPermissions
  }),
  transformWrite(from, to) {
    // Resize picture, then crop it to 1:1 aspect ratio, then compress it to 75% from its original quality
    const transform = sharp().resize(800,800).min().crop().toFormat('jpeg', {quality: 75});
    from.pipe(transform).pipe(to);
  }
});
 
// Gets picture's url by a given selector
Pictures.getPictureUrl = function (selector, platform = "") {
  const prefix = platform === "android" ? "/android_asset/www" :
    platform === "ios" ? "" : "";
 
  const picture = this.findOne(selector) || {};
  return picture.url || prefix + DEFAULT_PICTURE_URL;
};
 
function picturesPermissions(userId: string): boolean {
  return Meteor.isServer || !!userId;
}

You can look at a store as some sort of a wrapper for a collection, which will run different kind of a operations before it mutates it or fetches data from it. Note that we used GridFS because this way an uploaded file is split into multiple packets, which is more efficient for storage. We also defined a small utility function on that store which will retrieve a profile picture. If the ID was not found, it will return a link for the default picture. To make things convenient, we will also export the store from the index file:

13.15 Export pictures collection api/server/collections/index.ts
1
2
3
4
export * from './chats';
export * from './messages';
export * from './users';
export * from './pictures';

Now that we have the pictures store, and the server knows how to handle uploaded pictures, we will implement the upload stub in the PictureService:

13.16 Implement upload method src/services/picture.ts
2
3
4
5
6
7
8
9
10
 
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { Platform } from 'ionic-angular';
import { ImagePicker } from '@ionic-native/image-picker';
import { UploadFS } from 'meteor/jalik:ufs';
import { PicturesStore } from 'api/collections';
import * as _ from 'lodash';
import { DEFAULT_PICTURE_URL } from 'api/models';
 
@Injectable()
export class PictureService {
...some lines skipped...
  }
 
  upload(blob: File): Promise<any> {
    return new Promise((resolve, reject) => {
      const metadata: any = _.pick(blob, 'name', 'type', 'size');
 
      if (!metadata.name) {
        metadata.name = DEFAULT_PICTURE_URL;
      }
 
      const upload = new UploadFS.Uploader({
        data: blob,
        file: metadata,
        store: PicturesStore,
        onComplete: resolve,
        onError: reject
      });
 
      upload.start();
    });
  }
 
  convertURLtoBlob(url: string, options = {}): Promise<File> {

Since sharp is a server-only package, and it is not supported by the client, at all, we will replace it with an empty dummy-object so errors won't occur. This requires us to change the Webpack config as shown below:

13.17 Ignore sharp package on client side webpack.config.js
81
82
83
84
85
86
87
88
89
  },
 
  externals: [
    {
      sharp: '{}'
    },
    resolveExternals
  ],
 

View Picture Messages

We will now add the support for picture typed messages in the MessagesPage, so whenever we send a picture, we will be able to see them in the messages list like any other message:

13.18 Added view for picture message src/pages/messages/messages.html
24
25
26
27
28
29
30
              <agm-marker [latitude]="getLocation(message.content).lat" [longitude]="getLocation(message.content).lng"></agm-marker>
            </agm-map>
          </div>
          <img *ngIf="message.type == 'picture'" (click)="showPicture($event)" class="message-content message-content-picture" [src]="message.content">
 
          <span class="message-timestamp">{{ message.createdAt | amDateFormat: 'HH:mm' }}</span>
        </div>

As you can see, we also bound the picture message to the click event, which means that whenever we click on it, a picture viewer should be opened with the clicked picture. Let's create the component for that picture viewer:

13.19 Create show picture component src/pages/messages/show-picture.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Component } from '@angular/core';
import { NavParams } from 'ionic-angular';
 
@Component({
  selector: 'show-picture',
  templateUrl: 'show-picture.html'
})
export class ShowPictureComponent {
  pictureSrc: string;
 
  constructor(private navParams: NavParams) {
    this.pictureSrc = this.navParams.get('pictureSrc');
  }
}
13.20 Create show picture template src/pages/messages/show-picture.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<ion-header>
  <ion-toolbar color="whatsapp">
    <ion-title>Show Picture</ion-title>
 
    <ion-buttons left>
      <button ion-button class="dismiss-button" (click)="viewCtrl.dismiss()"><ion-icon name="close"></ion-icon></button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>
 
<ion-content class="show-picture">
  <img class="picture" [src]="pictureSrc">
</ion-content>
13.21 Create show pictuer component styles src/pages/messages/show-picture.scss
1
2
3
4
5
6
7
8
9
10
.show-picture {
  background-color: black;
 
  .picture {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
}
13.22 Import ShowPictureComponent src/app/app.module.ts
15
16
17
18
19
20
21
 
34
35
36
37
38
39
40
41
 
57
58
59
60
61
62
63
64
import { MessagesAttachmentsComponent } from '../pages/messages/messages-attachments';
import { MessagesOptionsComponent } from '../pages/messages/messages-options';
import { NewLocationMessageComponent } from '../pages/messages/location-message';
import { ShowPictureComponent } from '../pages/messages/show-picture';
import { ProfilePage } from '../pages/profile/profile';
import { VerificationPage } from '../pages/verification/verification';
import { PhoneService } from '../services/phone';
...some lines skipped...
    NewChatComponent,
    MessagesOptionsComponent,
    MessagesAttachmentsComponent,
    NewLocationMessageComponent,
    ShowPictureComponent
  ],
  imports: [
    BrowserModule,
...some lines skipped...
    NewChatComponent,
    MessagesOptionsComponent,
    MessagesAttachmentsComponent,
    NewLocationMessageComponent,
    ShowPictureComponent
  ],
  providers: [
    StatusBar,

And now that we have that component ready, we will implement the showPicture method in the MessagesPage component, which will create a new instance of the ShowPictureComponent:

13.23 Implement showPicture method src/pages/messages/messages.ts
1
2
3
4
5
 
9
10
11
12
13
14
15
 
32
33
34
35
36
37
38
39
 
268
269
270
271
272
273
274
275
276
277
278
279
import { Component, OnInit, OnDestroy, ElementRef } from '@angular/core';
import { NavParams, PopoverController, ModalController } from 'ionic-angular';
import { Chat, Message, MessageType, Location } from 'api/models';
import { Messages } from 'api/collections';
import { MeteorObservable } from 'meteor-rxjs';
...some lines skipped...
import { Subscription, Observable, Subscriber } from 'rxjs';
import { MessagesAttachmentsComponent } from './messages-attachments';
import { PictureService } from '../../services/picture';
import { ShowPictureComponent } from './show-picture';
 
@Component({
  selector: 'messages-page',
...some lines skipped...
    navParams: NavParams,
    private el: ElementRef,
    private popoverCtrl: PopoverController,
    private pictureService: PictureService,
    private modalCtrl: ModalController
  ) {
    this.selectedChat = <Chat>navParams.get('chat');
    this.title = this.selectedChat.title;
...some lines skipped...
      zoom: Math.min(splitted[2] || 0, 19)
    };
  }
 
  showPicture({ target }: Event) {
    const modal = this.modalCtrl.create(ShowPictureComponent, {
      pictureSrc: (<HTMLImageElement>target).src
    });
 
    modal.present();
  }
}

Profile Picture

We have the ability to send picture messages. Now we will add the ability to change the user's profile picture using the infrastructure we've just created. To begin with, we will define a new property to our User model called pictureId, which will be used to determine the belonging profile picture of the current user:

13.24 Add pictureId property to Profile api/server/models.ts
3
4
5
6
7
8
9
export interface Profile {
  name?: string;
  picture?: string;
  pictureId?: string;
}
 
export enum MessageType {

We will bind the editing button in the profile selection page into an event handler:

13.25 Add event for changing profile picture src/pages/profile/profile.html
11
12
13
14
15
16
17
<ion-content class="profile-page-content">
  <div class="profile-picture">
    <img *ngIf="picture" [src]="picture">
    <ion-icon name="create" (click)="selectProfilePicture()"></ion-icon>
  </div>
 
  <ion-item class="profile-name">

And we will add all the missing logic in the component, so the pictureId will be transformed into and actual reference, and so we can have the ability to select a picture from our gallery and upload it:

13.26 Implement pick, update and set of profile image src/pages/profile/profile.ts
1
2
3
4
5
6
7
8
9
10
 
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
import { Component, OnInit } from '@angular/core';
import { Profile } from 'api/models';
import { AlertController, NavController, Platform } from 'ionic-angular';
import { MeteorObservable } from 'meteor-rxjs';
import { ChatsPage } from '../chats/chats';
import { PictureService } from '../../services/picture';
import { Pictures } from 'api/collections';
 
@Component({
  selector: 'profile',
...some lines skipped...
 
  constructor(
    private alertCtrl: AlertController,
    private navCtrl: NavController,
    private pictureService: PictureService,
    private platform: Platform
  ) {}
 
  ngOnInit(): void {
    this.profile = Meteor.user().profile || {
      name: ''
    };
 
    MeteorObservable.subscribe('user').subscribe(() => {
      let platform = this.platform.is('android') ? "android" :
        this.platform.is('ios') ? "ios" : "";
      platform = this.platform.is('cordova') ? platform : "";
 
      this.picture = Pictures.getPictureUrl(this.profile.pictureId, platform);
    });
  }
 
  selectProfilePicture(): void {
    this.pictureService.select().then((blob) => {
      this.uploadProfilePicture(blob);
    })
      .catch((e) => {
        this.handleError(e);
      });
  }
 
  uploadProfilePicture(blob: File): void {
    this.pictureService.upload(blob).then((picture) => {
      this.profile.pictureId = picture._id;
      this.picture = picture.url;
    })
      .catch((e) => {
        this.handleError(e);
      });
  }
 
  updateProfile(): void {

We will also define a new hook in the Meteor.users collection so whenever we update the profile picture, the previous one will be removed from the data-base. This way we won't have some unnecessary data in our data-base, which will save us some precious storage:

13.27 Add after hook for user modification api/server/collections/users.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { MongoObservable } from 'meteor-rxjs';
import { Meteor } from 'meteor/meteor';
import { User } from '../models';
import { Pictures } from './pictures';
 
export const Users = MongoObservable.fromExisting<User>(Meteor.users);
 
// Dispose unused profile pictures
Meteor.users.after.update(function (userId, doc, fieldNames, modifier, options) {
  if (!doc.profile) return;
  if (!this.previous.profile) return;
  if (doc.profile.pictureId == this.previous.profile.pictureId) return;
 
  Pictures.collection.remove({ _id: this.previous.profile.pictureId });
}, { fetchPrevious: true });

Collection hooks are not part of Meteor's official API and are added through a third-party package called matb33:collection-hooks. This requires us to install the necessary type definition:

$ npm install --save-dev @types/meteor-collection-hooks

Now we need to import the type definition we've just installed in the tsconfig.json file:

13.29 Import meteor-collection-hooks typings api/tsconfig.json
18
19
20
21
22
23
24
25
    "types": [
      "@types/meteor",
      "@types/meteor-accounts-phone",
      "@types/meteor-publish-composite",
      "@types/meteor-collection-hooks"
    ]
  },
  "exclude": [
13.29 Import meteor-collection-hooks typings tsconfig.json
21
22
23
24
25
26
27
28
    "noImplicitAny": false,
    "types": [
      "@types/meteor",
      "@types/meteor-accounts-phone",
      "@types/meteor-collection-hooks"
    ]
  },
  "include": [

We now add a user publication which should be subscribed whenever we initialize the ProfilePage. This subscription should fetch some data from other collections which is related to the user which is currently logged in; And to be more specific, the document associated with the profileId defined in the User model:

13.30 Add user publication api/server/publications.ts
2
3
4
5
6
7
8
 
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import { Users } from './collections/users';
import { Messages } from './collections/messages';
import { Chats } from './collections/chats';
import { Pictures } from './collections/pictures';
 
Meteor.publishComposite('users', function(
  pattern: string
...some lines skipped...
    ]
  };
});
 
Meteor.publish('user', function () {
  if (!this.userId) {
    return;
  }
 
  const profile = Users.findOne(this.userId).profile || {};
 
  return Pictures.collection.find({
    _id: profile.pictureId
  });
});

We will also modify the users and chats publication, so each user will contain its corresponding picture document as well:

13.31 Added images to users publication api/server/publications.ts
1
2
3
4
 
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { User, Message, Chat, Picture } from './models';
import { Users } from './collections/users';
import { Messages } from './collections/messages';
import { Chats } from './collections/chats';
...some lines skipped...
        fields: { profile: 1 },
        limit: 15
      });
    },
 
    children: [
      <PublishCompositeConfig1<User, Picture>> {
        find: (user) => {
          return Pictures.collection.find(user.profile.pictureId, {
            fields: { url: 1 }
          });
        }
      }
    ]
  };
});
 
13.32 Add images to chats publication api/server/publications.ts
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
          }, {
            fields: { profile: 1 }
          });
        },
        children: [
          <PublishCompositeConfig2<Chat, User, Picture>> {
            find: (user, chat) => {
              return Pictures.collection.find(user.profile.pictureId, {
                fields: { url: 1 }
              });
            }
          }
        ]
      }
    ]
  };

Since we already set up some collection hooks on the users collection, we can take it a step further by defining collection hooks on the chat collection, so whenever a chat is being removed, all its corresponding messages will be removed as well:

13.33 Add hook for removing unused messages api/server/collections/chats.ts
1
2
3
4
5
6
7
8
9
10
import { MongoObservable } from 'meteor-rxjs';
import { Chat } from '../models';
import { Messages } from './messages';
 
export const Chats = new MongoObservable.Collection<Chat>('chats');
 
// Dispose unused messages
Chats.collection.after.remove(function (userId, doc) {
  Messages.collection.remove({ chatId: doc._id });
});

We will now update the updateProfile method in the server to accept pictureId, so whenever we pick up a new profile picture the server won't reject it:

13.34 Allow updating pictureId api/server/methods.ts
59
60
61
62
63
64
65
66
      'User must be logged-in to create a new chat');
 
    check(profile, {
      name: nonEmptyString,
      pictureId: Match.Maybe(nonEmptyString)
    });
 
    Meteor.users.update(this.userId, {

Now we will update the users fabrication in our server's initialization, so instead of using hard-coded URLs, we will insert them as new documents to the PicturesCollection:

13.35 Update creation of users stubs api/server/main.ts
1
2
3
4
5
 
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';
 
...some lines skipped...
    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');
}

In order for ufs-gridfs to work properly on Android we need to specify how the client is supposed to reach the backend and how to generate the URLs when importing the images:

13.36 Set ROOT_URL environmental parameter before launching Meteor 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:3000 && meteor run --settings private/settings.json",
    "api:reset": "cd api && meteor reset",
    "clean": "ionic-app-scripts clean",
    "build": "ionic-app-scripts build",
    "lint": "ionic-app-scripts lint",

You will have to change meteor.linuxsystems.it with your own IP or at least put an entry for it into your /etc/hosts.

To avoid some unexpected behaviors, we will reset our data-base so our server can re-fabricate the data:

$ npm run meteor:reset

NOTE: we used $ npm run meteor:reset instead of api$ meteor reset because we need to set the environmental variable to let ufs generate the right URLs during the initial importation.

We will now update the ChatsPage to add the belonging picture for each chat during transformation:

13.37 Fetch user image from server src/pages/chats/chats.ts
1
2
3
4
5
6
7
 
19
20
21
22
23
24
25
26
 
49
50
51
52
53
54
55
56
57
58
59
60
import { Component, OnInit } from '@angular/core';
import { Chats, Messages, Users, Pictures } from 'api/collections';
import { Chat, Message } from 'api/models';
import { NavController, PopoverController, ModalController, AlertController, Platform } from 'ionic-angular';
import { MeteorObservable } from 'meteor-rxjs';
import { Observable, Subscriber } from 'rxjs';
import { MessagesPage } from '../messages/messages';
...some lines skipped...
    private navCtrl: NavController,
    private popoverCtrl: PopoverController,
    private modalCtrl: ModalController,
    private alertCtrl: AlertController,
    private platform: Platform) {
    this.senderId = Meteor.userId();
  }
 
...some lines skipped...
 
        if (receiver) {
          chat.title = receiver.profile.name;
 
          let platform = this.platform.is('android') ? "android" :
            this.platform.is('ios') ? "ios" : "";
          platform = this.platform.is('cordova') ? platform : "";
 
          chat.picture = Pictures.getPictureUrl(receiver.profile.pictureId, platform);
        }
 
        // This will make the last message reactive

And we will do the same in the NewChatComponent:

13.38 Use the new pictureId field for new chat modal src/pages/chats/new-chat.html
26
27
28
29
30
31
32
<ion-content class="new-chat">
  <ion-list class="users">
    <button ion-item *ngFor="let user of users | async" class="user" (click)="addChat(user)">
      <img class="user-picture" [src]="getPic(user.profile.pictureId)">
      <h2 class="user-name">{{user.profile.name}}</h2>
    </button>
  </ion-list>
13.39 Implement getPic src/pages/chats/new-chat.ts
1
2
3
4
5
6
7
 
18
19
20
21
22
23
24
25
 
108
109
110
111
112
113
114
115
116
117
118
119
import { Component, OnInit } from '@angular/core';
import { Chats, Users, Pictures } from 'api/collections';
import { User } from 'api/models';
import { AlertController, Platform, ViewController } from 'ionic-angular';
import { MeteorObservable } from 'meteor-rxjs';
import * as _ from 'lodash';
import { Observable, Subscription, BehaviorSubject } from 'rxjs';
...some lines skipped...
 
  constructor(
    private alertCtrl: AlertController,
    private viewCtrl: ViewController,
    private platform: Platform
  ) {
    this.senderId = Meteor.userId();
    this.searchPattern = new BehaviorSubject(undefined);
...some lines skipped...
 
    alert.present();
  }
 
  getPic(pictureId): string {
    let platform = this.platform.is('android') ? "android" :
      this.platform.is('ios') ? "ios" : "";
    platform = this.platform.is('cordova') ? platform : "";
 
    return Pictures.getPictureUrl(pictureId, platform);
  }
}