Fork me on GitHub

WhatsApp Clone with Ionic 2 and Meteor 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 intrested 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 server/imports/methods/methods.ts
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
 
    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);
  },
  addMessage(chatId: string, content: string): void {
    if (!this.userId) throw new Meteor.Error('unauthorized',
      'User must be logged-in to create a new chat');

We have a Method, now we have to implement it in the UI.

Each chat has two buttons, one for sending attachements and one to open options menu.

Let's create that menu by creating a new component called MessagesOptionsComponent:

7.3 Create MessagesOptionsComponent client/imports/pages/chat/messages-options.component.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
import {Component} from '@angular/core';
import {NavParams, NavController, ViewController, AlertController} from 'ionic-angular';
import {Meteor} from 'meteor/meteor';
import {MeteorObservable} from 'meteor-rxjs';
import template from './messages-options.component.html';
import style from "./messages-options.component.scss";
import {TabsContainerComponent} from '../tabs-container/tabs-container.component';
 
@Component({
  selector: 'messages-options',
  template,
  styles: [
    style
  ]
})
export class MessagesOptionsComponent {
  constructor(
    private navCtrl: NavController, 
    private viewCtrl: ViewController,
    private alertCtrl: AlertController,
    private 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(TabsContainerComponent, {}, {
            animate: true
          });
        });
      },
      error: (e: Error) => {
        alert.dismiss().then(() => {
          if (e) return this.handleError(e);
  
          this.navCtrl.setRoot(TabsContainerComponent, {}, {
            animate: true
          });
        });
      }
    });
  }
 
  private handleError(e: Error): void {
    console.error(e);
 
    const alert = this.alertCtrl.create({
      title: 'Oops!',
      message: e.message,
      buttons: ['OK']
    });
 
    alert.present();
  }
}
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>
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 client/imports/app/app.module.ts
10
11
12
13
14
15
16
 
23
24
25
26
27
28
29
30
 
36
37
38
39
40
41
42
43
import {ProfileComponent} from '../pages/auth/profile.component';
import {ChatsOptionsComponent} from '../pages/chats/chats-options.component';
import {NewChatComponent} from '../pages/chats/new-chat.component';
import {MessagesOptionsComponent} from '../pages/chat/messages-options.component';
 
@NgModule({
  // Components, Pipes, Directive
...some lines skipped...
    VerificationComponent,
    ProfileComponent,
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent
  ],
  // Entry Components
  entryComponents: [
...some lines skipped...
    VerificationComponent,
    ProfileComponent,
    ChatsOptionsComponent,
    NewChatComponent,
    MessagesOptionsComponent
  ],
  // Providers
  providers: [

Great! Now we can define component's method to open the options:

7.7 Define component's method to open options client/imports/pages/chat/messages-page.component.ts
1
2
3
4
5
 
7
8
9
10
11
12
13
 
26
27
28
29
30
31
32
33
34
35
 
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import {Component, OnInit, OnDestroy} from "@angular/core";
import {NavParams, PopoverController} from "ionic-angular";
import {Meteor} from 'meteor/meteor';
import {Chat} from "../../../../both/models/chat.model";
import {Messages} from "../../../../both/collections/messages.collection";
...some lines skipped...
import {Message} from "../../../../both/models/message.model";
import template from "./messages-page.component.html";
import style from "./messages-page.component.scss";
import {MessagesOptionsComponent} from './messages-options.component';
import {MeteorObservable} from "meteor-rxjs";
 
@Component({
...some lines skipped...
  private message = "";
  private autoScroller: MutationObserver;
 
  constructor(
    navParams: NavParams,
    private popoverCtrl: PopoverController
  ) {
    this.selectedChat = <Chat>navParams.get('chat');
    this.title = this.selectedChat.title;
    this.picture = this.selectedChat.picture;
...some lines skipped...
    return this.messagesList.querySelector('.scroll-content');
  }
 
  showOptions(): void {
    const popover = this.popoverCtrl.create(MessagesOptionsComponent, {
      chat: this.selectedChat
    }, {
      cssClass: 'options-popover'
    });
 
    popover.present();
  }
 
  onInputKeypress({keyCode}: KeyboardEvent): void {
    if (keyCode == 13) {
      this.sendMessage();

One thing missing, add this method to the view:

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>

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 inorder to stop all the automatic publication of information is to remove the autopublish package from the Meteor server:

$ meteor remove autopublish

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

$ meteor add reywood:publish-composite

And we will install its belonging typings as well

$ typings install dt~meteor-publish-composite --save --global

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

Create a file named users.publication.ts under the server/imports/publications directory with the following contents:

7.12 Create publication for Users server/imports/publications/users.publication.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
 
import { Users } from '../../../both/collections/users.collection';
import { User } from '../../../both/models/user.model';
 
Meteor.publish('users', function(): Mongo.Cursor<User> {
  if (!this.userId) return;
 
  return Users.collection.find({}, {
    fields: {
      profile: 1
    }
  });
});

Do the same but for Messages:

7.14 Add publication for Messages server/imports/publications/messages.publication.ts
1
2
3
4
5
6
7
8
9
10
11
12
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
 
import { Messages } from '../../../both/collections/messages.collection';
import { Message } from '../../../both/models/message.model';
 
Meteor.publish('messages', function(chatId: string): Mongo.Cursor<Message> {
  if (!this.userId) return;
  if (!chatId) return;
 
  return Messages.collection.find({chatId});
});

Use Meteor.publishComposite from the package we installed and create a publication of Chats:

7.13 Add publication for Chats server/imports/publications/chats.publication.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
import { Meteor } from 'meteor/meteor';
 
import { Chats } from '../../../both/collections/chats.collection';
import { Chat } from '../../../both/models/chat.model';
import { Messages } from '../../../both/collections/messages.collection';
import { Message } from '../../../both/models/message.model';
import { Users } from '../../../both/collections/users.collection';
import { User } from '../../../both/models/user.model';
 
Meteor.publishComposite('chats', function(): PublishCompositeConfig<Chat> {
  if (!this.userId) return;
 
  return {
    find: () => {
      return Chats.collection.find({memberIds: this.userId});
    },
 
    children: [
      <PublishCompositeConfig1<Chat, Message>> {
        find: (chat) => {
          return Messages.collection.find({chatId: chat._id}, {
            sort: {createdAt: -1},
            limit: 1
          });
        }
      },
      <PublishCompositeConfig1<Chat, User>> {
        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:

7.15 Expose publications server/main.ts
1
2
3
4
5
6
7
8
import { Main } from './imports/server-main/main';
import './imports/methods/methods';
import './imports/publications/chats.publication';
import './imports/publications/messages.publication';
import './imports/publications/users.publication';
import './imports/api/sms';
 
const mainInstance = new Main();

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

2
3
4
5
6
7
8
 
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
import template from "./chats.component.html"
import {Observable} from "rxjs";
import {Meteor} from 'meteor/meteor';
import {MeteorObservable} from 'meteor-rxjs';
import {Chat} from "../../../../both/models/chat.model";
import * as moment from "moment";
import style from "./chats.component.scss";
...some lines skipped...
 
  ngOnInit() {
    this.senderId = Meteor.userId();
 
    MeteorObservable.subscribe('chats').subscribe(() => {
      MeteorObservable.autorun().subscribe(() => {
        this.chats = Chats
          .find({})
          .mergeMap<Chat[]>(chats =>
            Observable.combineLatest(
              ...chats.map(chat =>
 
                Messages.find({ chatId: chat._id }, { sort: { createdAt: -1 }, limit: 1 })
                  .startWith(null)
                  .map(messages => {
                    if (messages) chat.lastMessage = messages[0];
                    return chat;
                  })
 
              )
            )
          ).map(chats => {
            chats.forEach(chat => {
              const receiver = Meteor.users.findOne(chat.memberIds.find(memberId => memberId !== this.senderId))
 
              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:

31
32
33
34
35
36
37
38
39
40
  }
 
  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:

37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
  }
 
  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();

{{{nav_step prev_ref="https://angular-meteor.com/tutorials/whatsapp2/meteor/1.0.0/chats-mutations" next_ref="https://angular-meteor.com/tutorials/whatsapp2/meteor/1.0.0/summary"}}}