Fork me on GitHub

WhatsApp Clone with Meteor and Ionic 2 CLI

Legacy Tutorial Version (Last Update: 22.11.2016)

Privacy & Security

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

In this step we gonna take care of the app's security and encapsulation, since we don't want the users to do whatever they want, and we don't want them to be able to see content which is irrelevant for them.

We gonna start by removing a Meteor package named insecure.

This package provides the client with the ability to run collection mutation methods. This is a behavior we are not interested in since removing data and creating data should be done in the server and only after certain validations.

Meteor includes this package by default only for development purposes and it should be removed once our app is ready for production.

So let's remove this package by running this command:

$ meteor remove insecure

With that we're able to add ability to remove chats:

7.2 Define 'removeChat' Method api/server/methods.ts
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
 
      Chats.insert(chat);
    },
    removeChat(chatId: string): void {
      if (!this.userId) throw new Meteor.Error('unauthorized',
        'User must be logged-in to remove chat');
 
      check(chatId, nonEmptyString);
 
      const chatExists = !!Chats.collection.find(chatId).count();
 
      if (!chatExists) throw new Meteor.Error('chat-not-exists',
        'Chat doesn\'t exist');
 
      Messages.remove({chatId});
      Chats.remove(chatId);
    },
    updateProfile(profile: Profile): void {
      if (!this.userId) throw new Meteor.Error('unauthorized',
        'User must be logged-in to create a new chat');

Now that we have a dedicated method in the server, we can go ahead and take advantage of in in our app. In the messages page we have two buttons in the navigation bar, one is for sending attachments and one to open the options menu. The options menu is gonna be a pop-over, the same as in the chats page. Let's implement its component, which is gonna be called MessagesOptionsComponent:

7.3 Create MessagesOptionsComponent src/pages/messages-options/messages-options.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 [email protected]/core';
import { NavParams, NavController, ViewController, AlertController } from 'ionic-angular';
import { MeteorObservable } from 'meteor-rxjs';
import { TabsPage } from "../tabs/tabs";
 
@Component({
  selector: 'messages-options',
  templateUrl: 'messages-options.html'
})
export class MessagesOptionsComponent {
  constructor(
    public navCtrl: NavController,
    public viewCtrl: ViewController,
    public alertCtrl: AlertController,
    public params: NavParams
  ) {}
 
  remove(): void {
    const alert = this.alertCtrl.create({
      title: 'Remove',
      message: 'Are you sure you would like to proceed?',
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel'
        },
        {
          text: 'Yes',
          handler: () => {
            this.handleRemove(alert);
            return false;
          }
        }
      ]
    });
 
    this.viewCtrl.dismiss().then(() => {
      alert.present();
    });
  }
 
  private handleRemove(alert): void {
    MeteorObservable.call('removeChat', this.params.get('chat')._id).subscribe({
      next: () => {
        alert.dismiss().then(() => {
          this.navCtrl.setRoot(TabsPage, {}, {
            animate: true
          });
        });
      },
      error: (e: Error) => {
        alert.dismiss().then(() => {
          if (e) return this.handleError(e);
 
          this.navCtrl.setRoot(TabsPage, {}, {
            animate: true
          });
        });
      }
    });
  }
 
  private handleError(e: Error): void {
    console.error(e);
 
    const alert = this.alertCtrl.create({
      title: 'Oops!',
      message: e.message,
      buttons: ['OK']
    });
 
    alert.present();
  }
}
7.4 Create MessagesOptionsComponent view src/pages/messages-options/messages-options.html
1
2
3
4
5
6
7
8
<ion-content class="chats-options-page-content">
  <ion-list class="options">
    <button ion-item class="option option-remove" (click)="remove()">
      <ion-icon name="remove" class="option-icon"></ion-icon>
      <div class="option-name">Remove</div>
    </button>
  </ion-list>
</ion-content>
7.5 Create MessagesOptionsComponent view style src/pages/messages-options/messages-options.scss
1
2
3
4
5
6
7
8
9
10
11
12
13
.chats-options-page-content {
  .options {
    margin: 0;
  }
 
  .option-name {
    float: left;
  }
 
  .option-icon {
    float: right;
  }
}
7.6 Register the component src/app/app.module.ts
10
11
12
13
14
15
16
 
23
24
25
26
27
28
29
 
39
40
41
42
43
44
45
46
import { ProfileComponent } from "../pages/profile/profile";
import { ChatsOptionsComponent } from "../pages/chat-options/chat-options";
import { NewChatComponent } from "../pages/new-chat/new-chat";
import { MessagesOptionsComponent } from "../pages/messages-options/messages-options";
 
@NgModule({
  declarations: [
...some lines skipped...
    ProfileComponent,
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent
  ],
  imports: [
    IonicModule.forRoot(MyApp),
...some lines skipped...
    VerificationComponent,
    ProfileComponent,
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent
  ],
  providers: [{provide: ErrorHandler, useClass: IonicErrorHandler}]
})

Now we can go ahead and implement the method in the messages page for showing this popover:

7.7 Define component's method to open options src/pages/messages/messages.ts
1
2
3
4
5
6
7
8
9
10
 
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { Component, OnInit, OnDestroy, ElementRef } from "@angular/core";
import { NavParams, PopoverController } from "ionic-angular";
import { Chat, Message } from "api/models/whatsapp-models";
import { Messages } from "api/collections/whatsapp-collections";
import { Observable } from "rxjs";
import { MeteorObservable } from "meteor-rxjs";
import { MessagesOptionsComponent } from "../messages-options/messages-options";
 
@Component({
  selector: "messages-page",
...some lines skipped...
  autoScroller: MutationObserver;
  senderId: string;
 
  constructor(navParams: NavParams, element: ElementRef, public popoverCtrl: PopoverController) {
    this.selectedChat = <Chat>navParams.get('chat');
    this.title = this.selectedChat.title;
    this.picture = this.selectedChat.picture;
    this.senderId = Meteor.userId();
  }
 
  showOptions(): void {
    const popover = this.popoverCtrl.create(MessagesOptionsComponent, {
      chat: this.selectedChat
    }, {
      cssClass: 'options-popover'
    });
 
    popover.present();
  }
 
  private get messagesPageContent(): Element {

And last but not least, let's update our view and bind the event to its handler:

7.8 Implement it in the view src/pages/messages/messages.html
8
9
10
11
12
13
14
 
    <ion-buttons end>
      <button ion-button icon-only class="attach-button"><ion-icon name="attach"></ion-icon></button>
      <button ion-button icon-only class="settings-button" (click)="showOptions()"><ion-icon name="more"></ion-icon></button>
    </ion-buttons>
  </ion-navbar>
</ion-header>

Now let's use the chat removal method in the chats view once we slide a chat item to the right and press the remove button:

7.9 Use chat removal method in cahts component src/pages/chats/chats.ts
2
3
4
5
6
7
8
 
17
18
19
20
21
22
23
24
 
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
import { Observable } from "rxjs";
import { Chat } from "api/models/whatsapp-models";
import { Chats, Messages, Users } from "api/collections/whatsapp-collections";
import { NavController, PopoverController, ModalController, AlertController } from "ionic-angular";
import { MessagesPage } from "../messages/messages";
import { ChatsOptionsComponent } from "../chat-options/chat-options";
import { NewChatComponent } from "../new-chat/new-chat";
...some lines skipped...
  constructor(
    public navCtrl: NavController,
    public popoverCtrl: PopoverController,
    public modalCtrl: ModalController,
    public alertCtrl: AlertController
  ) {}
 
  ngOnInit() {
...some lines skipped...
  }
 
  removeChat(chat: Chat): void {
    MeteorObservable.call('removeChat', chat._id).subscribe({
      complete: () => {
        this.chats = this.chats.zone();
      },
      error: (e: Error) => {
        if (e) this.handleError(e);
      }
    });
  }
 
  private handleError(e: Error): void {
    console.error(e);
 
    const alert = this.alertCtrl.create({
      title: 'Oops!',
      message: e.message,
      buttons: ['OK']
    });
 
    alert.present();
  }
}

Right now all the chats are published to all the clients which is not very good for privacy. Let's fix that.

First thing we need to do in order to stop all the automatic publication of information is to remove the autopublish package from the Meteor server:

$ meteor remove autopublish

Now we need to explicitly define our publications. Let's start by sending the users' information:

7.11 Added users publication api/server/publications.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { Users, Messages, Chats } from '../collections/whatsapp-collections';
import { User, Message, Chat } from 'api/models/whatsapp-models';
 
export function initPublications() {
  Meteor.publish('users', function(): Mongo.Cursor<User> {
    if (!this.userId) return;
 
    return Users.collection.find({}, {
      fields: {
        profile: 1
      }
    });
  });
 
}

And add the messages:

7.12 Added messages publication api/server/publications.ts
14
15
16
17
18
19
20
21
22
23
24
    });
  });
 
  Meteor.publish('messages', function(chatId: string): Mongo.Cursor<Message> {
    if (!this.userId) return;
    if (!chatId) return;
 
    return Messages.collection.find({chatId});
  });
 
}

We will now add the publish-composite package which will help us implement joined collection publications.

$ meteor add reywood:publish-composite

And we will install its typings declarations:

$ npm install --save @types/meteor-publish-composite

And import them:

7.14 Add meteor-publish-composite type declarations package.json
21
22
23
24
25
26
27
    "@ionic/storage": "1.1.6",
    "@types/meteor": "^1.3.31",
    "@types/meteor-accounts-phone": "0.0.5",
    "@types/meteor-publish-composite": "0.0.32",
    "@types/underscore": "^1.7.36",
    "accounts-base-client-side": "^0.1.1",
    "accounts-phone": "0.0.1",
7.14 Add meteor-publish-composite type declarations src/declarations.d.ts
13
14
15
16
17
18
19
*/
/// <reference types="meteor-typings" />
/// <reference types="@types/meteor-accounts-phone" />
/// <reference types="@types/meteor-publish-composite" />
/// <reference types="@types/underscore" />
/// <reference path="../api/models/whatsapp-models.d.ts" />
declare module '*';

Now we will use Meteor.publishComposite from the package we installed and create a publication of Chats:

7.15 Added chats publication api/server/publications.ts
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
    return Messages.collection.find({chatId});
  });
 
  Meteor.publishComposite('chats', function() {
    if (!this.userId) return;
 
    return {
      find: () => {
        return Chats.collection.find({memberIds: this.userId});
      },
 
      children: [
        {
          find: (chat) => {
            return Messages.collection.find({chatId: chat._id}, {
              sort: {createdAt: -1},
              limit: 1
            });
          }
        },
        {
          find: (chat) => {
            return Users.collection.find({
              _id: {$in: chat.memberIds}
            }, {
              fields: {profile: 1}
            });
          }
        }
      ]
    };
  });
}

The chats publication is a composite publication which is made of several nodes. First we gonna find all the relevant chats for the current user logged in. After we have the chats, we gonna return the following cursor for each chat document we found. First we gonna return all the last messages, and second we gonna return all the users we're currently chatting with.

Those publications are still not visible by server, we need to import and run the init method:

7.16 Init publication on server start api/server/main.ts
2
3
4
5
6
7
8
9
10
11
12
13
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
import { initMethods } from "./methods";
import { initPublications } from "./publications";
import { Users } from "../collections/whatsapp-collections";
 
Meteor.startup(() => {
  initMethods();
  initPublications();
 
  if (Meteor.settings) {
    Object.assign(Accounts._options, Meteor.settings['accounts-phone']);

Let's add the subscription for the chats publication in the chats component:

7.17 Subscribe to 'chats' src/pages/chats/chats.ts
6
7
8
9
10
11
12
 
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
import { MessagesPage } from "../messages/messages";
import { ChatsOptionsComponent } from "../chat-options/chat-options";
import { NewChatComponent } from "../new-chat/new-chat";
import { MeteorObservable } from 'meteor-rxjs';
 
@Component({
  templateUrl: 'chats.html'
...some lines skipped...
  ngOnInit() {
    this.senderId = Meteor.userId();
 
    MeteorObservable.subscribe('chats').subscribe(() => {
      MeteorObservable.autorun().subscribe(() => {
        if (this.chats) {
          this.chats.unsubscribe();
          this.chats = undefined;
        }
 
        this.chats = Chats
          .find({})
          .mergeMap((chats: Chat[]) =>
            Observable.combineLatest(
              ...chats.map((chat: Chat) =>
                Messages
                  .find({chatId: chat._id})
                  .startWith(null)
                  .map(messages => {
                    if (messages) chat.lastMessage = messages[0];
                    return chat;
                  })
              )
            )
          ).map(chats => {
            chats.forEach(chat => {
              chat.title = '';
              chat.picture = '';
 
              const receiver = Users.findOne(chat.memberIds.find(memberId => memberId !== this.senderId));
              if (!receiver) return;
 
              chat.title = receiver.profile.name;
              chat.picture = receiver.profile.picture;
            });
 
            return chats;
          }).zone();
      });
    });
  }
 
  addChat(): void {

The users publication publishes all the users' profiles, and we need to use it in the new chat dialog whenever we wanna create a new chat.

Let's subscribe to the users publication in the new chat component:

7.18 Subscribe to 'users' src/pages/new-chat/new-chat.ts
21
22
23
24
25
26
27
28
29
30
  }
 
  ngOnInit() {
    MeteorObservable.subscribe('users').subscribe(() => {
      MeteorObservable.autorun().subscribe(() => {
        this.users = this.findUsers().zone();
      });
    });
  }
 

The messages publication is responsible for bringing all the relevant messages for a certain chat. This publication is actually parameterized and it requires us to pass a chat id during subscription.

Let's subscribe to the messages publication in the messages component, and pass the current active chat id provided to us by the nav params:

7.19 Subscribe to 'messages' src/pages/messages/messages.ts
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
  }
 
  ngOnInit() {
    MeteorObservable.subscribe('messages', this.selectedChat._id).subscribe(() => {
      MeteorObservable.autorun().subscribe(() => {
        this.messages = Messages.find(
          {chatId: this.selectedChat._id},
          {sort: {createdAt: 1}}
        ).map((messages: Message[]) => {
          messages.forEach((message: Message) => {
            message.ownership = this.senderId == message.senderId ? 'mine' : 'other';
          });
 
          return messages;
        });
      });
    });
 
    this.autoScroller = this.autoScroll();