Fork me on GitHub

WhatsApp Clone with Ionic 2 and Meteor CLI

Legacy Tutorial Version (Last Update: 22.11.2016)

Messages Page

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 add the messages view and the ability to send messages.

Before we implement anything related to the messages pages, we first have to make sure that once we click on a chat item in the chats page, we will be promoted into its corresponding messages view.

Let's first implement the showMessages() method in the chats component

4.1 Added showMessage method client/imports/pages/chats/chats.component.ts
7
8
9
10
11
12
13
 
19
20
21
22
23
24
25
 
41
42
43
44
45
46
47
48
import {Chats} from "../../../../both/collections/chats.collection";
import {Message} from "../../../../both/models/message.model";
import {Messages} from "../../../../both/collections/messages.collection";
import {NavController} from "ionic-angular";
 
@Component({
  selector: "chats",
...some lines skipped...
export class ChatsComponent implements OnInit {
  chats: Observable<Chat[]>;
 
  constructor(private navCtrl: NavController) {
 
  }
 
...some lines skipped...
        )
      ).zone();
  }
 
  showMessages(chat): void {
    this.navCtrl.push(MessagesPage, {chat});
  }
}

And let's register the click event in the view:

4.2 Added the action to the button client/imports/pages/chats/chats.component.html
11
12
13
14
15
16
17
 
<ion-content class="chats-page-content">
  <ion-list class="chats">
    <button ion-item *ngFor="let chat of chats | async" class="chat" (click)="showMessages(chat)">
      <img class="chat-picture" [src]="chat.picture">
 
      <div class="chat-info">

Notice how we used we used a controller called NavController. The NavController is Ionic's new method to navigate in our app, we can also use a traditional router, but since in a mobile app we have no access to the url bar, this might come more in handy. You can read more about the NavController here.

Let's go ahead and implement the messages component. We'll call it MessagesPage:

4.3 Create a stub for the component client/imports/pages/chat/messages-page.component.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {Component, OnInit} from "@angular/core";
import {NavParams} from "ionic-angular";
import {Chat} from "../../../../both/models/chat.model";
 
@Component({
  selector: "messages-page",
  template: `Messages Page`
})
export class MessagesPage implements OnInit {
  private selectedChat: Chat;
 
  constructor(navParams: NavParams) {
    this.selectedChat = <Chat>navParams.get('chat');
 
    console.log("Selected chat is: ", this.selectedChat);
  }
 
  ngOnInit() {
 
  }
}

As you can see, in order to get the chat's id we used the NavParams service. This is a simple service which gives you access to a key-value storage containing all the parameters we've passed using the NavController. For more information about the NavParams service, see the following link.

Now it has to be added to AppModule:

4.4 Added the Component to the NgModule client/imports/app/app.module.ts
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import {TabsContainerComponent} from "../pages/tabs-container/tabs-container.component";
import {ChatsComponent} from "../pages/chats/chats.component";
import {MomentModule} from "angular2-moment";
import {MessagesPage} from "../pages/chat/messages-page.component";
 
@NgModule({
  // Components, Pipes, Directive
  declarations: [
    AppComponent,
    TabsContainerComponent,
    ChatsComponent,
    MessagesPage
  ],
  // Entry Components
  entryComponents: [
    AppComponent,
    TabsContainerComponent,
    ChatsComponent,
    MessagesPage
  ],
  // Providers
  providers: [

We've used MessagesPage in ChatsComponent but we haven't imported it yet, let's make it now:

4.5 Added the correct import client/imports/pages/chats/chats.component.ts
8
9
10
11
12
13
14
import {Message} from "../../../../both/models/message.model";
import {Messages} from "../../../../both/collections/messages.collection";
import {NavController} from "ionic-angular";
import {MessagesPage} from "../chat/messages-page.component";
 
@Component({
  selector: "chats",

Now we can add some data to the component. We need a title and a picture to use inside the chat window. We also need a message:

4.6 Add basic messages component client/imports/pages/chat/messages-page.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
import {Component, OnInit} from "@angular/core";
import {NavParams} from "ionic-angular";
import {Chat} from "../../../../both/models/chat.model";
import {Messages} from "../../../../both/collections/messages.collection";
import {Observable} from "rxjs";
import {Message} from "../../../../both/models/message.model";
import template from "./messages-page.component.html";
 
@Component({
  selector: "messages-page",
  template
})
export class MessagesPage implements OnInit {
  private selectedChat: Chat;
  private title: string;
  private picture: string;
  private messages: Observable<Message[]>;
 
  constructor(navParams: NavParams) {
    this.selectedChat = <Chat>navParams.get('chat');
    this.title = this.selectedChat.title;
    this.picture = this.selectedChat.picture;
  }
 
  ngOnInit() {
    let isEven = false;
 
    this.messages = Messages.find(
      {chatId: this.selectedChat._id},
      {sort: {createdAt: 1}}
    ).map((messages: Message[]) => {
      messages.forEach((message: Message) => {
        message.ownership = isEven ? 'mine' : 'other';
        isEven = !isEven;
      });
 
      return messages;
    });
  }
}

As you probably noticed, we added the ownership for each message. We're not able to determine the author of a message so we mark every even message as ours.

Let's add the ownership property to the model:

4.7 Add 'ownership' property to message model both/models/message.model.ts
2
3
4
5
6
7
  _id?: string;
  chatId?: string;
  content?: string;
  ownership?: string;
  createdAt?: Date;
}

One thing missing, the template:

4.8 Add basic messages view template client/imports/pages/chat/messages-page.component.html
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
<ion-header>
  <ion-navbar color="whatsapp" class="messages-page-navbar">
    <ion-buttons>
      <img class="chat-picture" [src]="picture">
    </ion-buttons>
 
    <ion-title class="chat-title">{{title}}</ion-title>
 
    <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"><ion-icon name="more"></ion-icon></button>
    </ion-buttons>
  </ion-navbar>
</ion-header>
 
<ion-content padding class="messages-page-content">
  <ion-scroll scrollY="true" class="messages">
    <div *ngFor="let message of messages | async" class="message-wrapper">
      <div [class]="'message message-' + message.ownership">
        <div class="message-content">{{message.content}}</div>
        <span class="message-timestamp">{{message.createdAt}}</span>
      </div>
    </div>
  </ion-scroll>
</ion-content>

The template has a picture and a title inside the Navigation Bar. It has also two buttons. Purpose of the first one is to send an attachment. The second one, just like in Chats, is to show more options. As the content, we used list of messages.

It doesn't look quite good as it should, let's add some style:

4.9 Add basic messages stylesheet client/imports/pages/chat/messages-page.component.scss
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
.messages-page-navbar {
  .chat-picture {
    width: 50px;
    border-radius: 50%;
    float: left;
  }
 
  .chat-title {
    line-height: 50px;
    float: left;
  }
}
 
.messages-page-content {
  .messages {
    height: 100%;
    background-image: url(/assets/chat-background.jpg);
    background-color: #E0DAD6;
    background-repeat: no-repeat;
    background-size: cover;
  }
 
  .message-wrapper {
    margin-bottom: 9px;
 
    &::after {
      content: "";
      display: table;
      clear: both;
    }
  }
 
  .message {
    display: inline-block;
    position: relative;
    max-width: 236px;
    border-radius: 7px;
    box-shadow: 0 1px 2px rgba(0, 0, 0, .15);
 
    &.message-mine {
      float: right;
      background-color: #DCF8C6;
 
      &::before {
        right: -11px;
        background-image: url(/assets/message-mine.png)
      }
    }
 
    &.message-other {
      float: left;
      background-color: #FFF;
 
      &::before {
        left: -11px;
        background-image: url(/assets/message-other.png)
      }
    }
 
    &.message-other::before, &.message-mine::before {
      content: "";
      position: absolute;
      bottom: 3px;
      width: 12px;
      height: 19px;
      background-position: 50% 50%;
      background-repeat: no-repeat;
      background-size: contain;
    }
 
    .message-content {
      padding: 5px 7px;
      word-wrap: break-word;
 
      &::after {
        content: " \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0";
        display: inline;
      }
    }
 
    .message-timestamp {
      position: absolute;
      bottom: 2px;
      right: 7px;
      font-size: 12px;
      color: gray;
    }
  }
}
4.9 Add basic messages stylesheet client/styles/messages-scroll.scss
1
2
3
4
5
.messages-page-content {
  > .scroll-content {
    margin: 42px -15px 99px !important;
  }
}

This stylesheet is partially complete with only one exception. There is a component dynamically added by Ionic using an element called ion-scroll. Dynamically added HTML content can't have an encapsulated style which is applied to the Angular component, therefore, we will have to declare the scroller's style globally if we want it to take effect:

4.9 Add basic messages stylesheet client/imports/pages/chat/messages-page.component.scss
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
.messages-page-navbar {
  .chat-picture {
    width: 50px;
    border-radius: 50%;
    float: left;
  }
 
  .chat-title {
    line-height: 50px;
    float: left;
  }
}
 
.messages-page-content {
  .messages {
    height: 100%;
    background-image: url(/assets/chat-background.jpg);
    background-color: #E0DAD6;
    background-repeat: no-repeat;
    background-size: cover;
  }
 
  .message-wrapper {
    margin-bottom: 9px;
 
    &::after {
      content: "";
      display: table;
      clear: both;
    }
  }
 
  .message {
    display: inline-block;
    position: relative;
    max-width: 236px;
    border-radius: 7px;
    box-shadow: 0 1px 2px rgba(0, 0, 0, .15);
 
    &.message-mine {
      float: right;
      background-color: #DCF8C6;
 
      &::before {
        right: -11px;
        background-image: url(/assets/message-mine.png)
      }
    }
 
    &.message-other {
      float: left;
      background-color: #FFF;
 
      &::before {
        left: -11px;
        background-image: url(/assets/message-other.png)
      }
    }
 
    &.message-other::before, &.message-mine::before {
      content: "";
      position: absolute;
      bottom: 3px;
      width: 12px;
      height: 19px;
      background-position: 50% 50%;
      background-repeat: no-repeat;
      background-size: contain;
    }
 
    .message-content {
      padding: 5px 7px;
      word-wrap: break-word;
 
      &::after {
        content: " \00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0";
        display: inline;
      }
    }
 
    .message-timestamp {
      position: absolute;
      bottom: 2px;
      right: 7px;
      font-size: 12px;
      color: gray;
    }
  }
}
4.9 Add basic messages stylesheet client/styles/messages-scroll.scss
1
2
3
4
5
.messages-page-content {
  > .scroll-content {
    margin: 42px -15px 99px !important;
  }
}

Now we can add it to the component:

4.10 Load the stylesheet into the Component client/imports/pages/chat/messages-page.component.ts
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {Observable} from "rxjs";
import {Message} from "../../../../both/models/message.model";
import template from "./messages-page.component.html";
import style from "./messages-page.component.scss";
 
@Component({
  selector: "messages-page",
  template,
  styles: [
    style
  ]
})
export class MessagesPage implements OnInit {
  private selectedChat: Chat;

This style requires us to add some assets, first we will create a copy called assets inside a public directory and then we will copy them like so:

$ mkdir public/assets
$ cd public/assets
$ wget https://github.com/Urigo/Ionic2-MeteorCLI-WhatsApp/blob/master/www/assets/chat-background.jpg
$ wget https://github.com/Urigo/Ionic2-MeteorCLI-WhatsApp/blob/master/www/assets/message-mine.jpg
$ wget https://github.com/Urigo/Ionic2-MeteorCLI-WhatsApp/blob/master/www/assets/message-other.jpg

Now we need to take care of the message's timestamp and format it, then again we gonna use angular2-moment only this time we gonna use a different format using the AmDateFormat pipe:

4.12 Apply date format pipe to messages view template client/imports/pages/chat/messages-page.component.html
18
19
20
21
22
23
24
    <div *ngFor="let message of messages | async" class="message-wrapper">
      <div [class]="'message message-' + message.ownership">
        <div class="message-content">{{message.content}}</div>
        <span class="message-timestamp">{{message.createdAt | amDateFormat: 'HH:MM'}}</span>
      </div>
    </div>
  </ion-scroll>

Our messages are set, but there is one really important feature missing and that's sending messages. Let's implement our message editor.

We will start with the view itself. We will add an input for editing our messages, a send button, and a record button whos logic won't be implemented in this tutorial since we only wanna focus on the text messaging system. To fulfill this layout we gonna use a tool-bar (ion-toolbar) inside a footer (ion-footer) and place it underneath the content of the view:

4.13 Add message editor to messages view template client/imports/pages/chat/messages-page.component.html
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
      </div>
    </div>
  </ion-scroll>
</ion-content>
 
<ion-footer>
  <ion-toolbar color="whatsapp" class="messages-page-footer" position="bottom">
    <ion-input [(ngModel)]="message" (keypress)="onInputKeypress($event)" class="message-editor" placeholder="Type a message"></ion-input>
 
    <ion-buttons end>
      <button ion-button icon-only *ngIf="message" class="message-editor-button" (click)="sendMessage()">
        <ion-icon name="send"></ion-icon>
      </button>
 
      <button ion-button icon-only *ngIf="!message" class="message-editor-button">
        <ion-icon name="mic"></ion-icon>
      </button>
    </ion-buttons>
  </ion-toolbar>
</ion-footer>

Our stylesheet requires few adjustments as well:

4.14 Add message-editor style to messages stylesheet client/imports/pages/chat/messages-page.component.scss
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
      color: gray;
    }
  }
}
 
.messages-page-footer {
  padding-right: 0;
 
  .message-editor {
    margin-left: 2px;
    padding-left: 5px;
    background: white;
    border-radius: 3px;
  }
 
  .message-editor-button {
    box-shadow: none;
    width: 50px;
    height: 50px;
    font-size: 17px;
    margin: auto;
  }
}

Now we can add handle message sending inside the component:

4.15 Add 'sendMessage()' handler to messages component client/imports/pages/chat/messages-page.component.ts
6
7
8
9
10
11
12
 
20
21
22
23
24
25
26
 
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import {Message} from "../../../../both/models/message.model";
import template from "./messages-page.component.html";
import style from "./messages-page.component.scss";
import {MeteorObservable} from "meteor-rxjs";
 
@Component({
  selector: "messages-page",
...some lines skipped...
  private title: string;
  private picture: string;
  private messages: Observable<Message[]>;
  private message = "";
 
  constructor(navParams: NavParams) {
    this.selectedChat = <Chat>navParams.get('chat');
...some lines skipped...
      return messages;
    });
  }
 
  onInputKeypress({keyCode}: KeyboardEvent): void {
    if (keyCode == 13) {
      this.sendMessage();
    }
  }
 
  sendMessage(): void {
    MeteorObservable.call('addMessage', this.selectedChat._id, this.message).zone().subscribe(() => {
      this.message = '';
    });
  }
}

As you can see, we used addMessage Method, which doesn't exist yet.

It the method which will add messages to our messages collection and run on both client's local cache and server. We're going to create a server/imports/methods/methods.ts file in our server and implement this method:

4.16 Add 'addMessage()' method to api server/imports/methods/methods.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {Meteor} from 'meteor/meteor';
import {Chats} from "../../../both/collections/chats.collection";
import {Messages} from "../../../both/collections/messages.collection";
 
Meteor.methods({
  addMessage(chatId: string, content: string): void {
    const chatExists = !!Chats.collection.find(chatId).count();
 
    if (!chatExists) throw new Meteor.Error('chat-not-exists',
      'Chat doesn\'t exist');
 
    Messages.collection.insert({
      chatId: chatId,
      content: content,
      createdAt: new Date()
    });
  }
});

It's not yet visible by server, let's change it:

4.17 Added import for the methods file server/main.ts
1
2
3
4
5
import { Main } from './imports/server-main/main';
import './imports/methods/methods';
 
const mainInstance = new Main();
mainInstance.start();

We would also like to validate some data sent to methods we define. For this we're going to use a utility package provided to us by Meteor and it's called check. Let's add it to the server:

$ meteor add check

And we're going use it in our method we just defined:

4.19 Add validations to 'addMessage()' method in api server/imports/methods/methods.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {Meteor} from 'meteor/meteor';
import {Chats} from "../../../both/collections/chats.collection";
import {Messages} from "../../../both/collections/messages.collection";
import {check, Match} from 'meteor/check';
 
const nonEmptyString = Match.Where((str) => {
  check(str, String);
  return str.length > 0;
});
 
Meteor.methods({
  addMessage(chatId: string, content: string): void {
    check(chatId, nonEmptyString);
    check(content, nonEmptyString);
 
    const chatExists = !!Chats.collection.find(chatId).count();
 
    if (!chatExists) throw new Meteor.Error('chat-not-exists',

The nonEmptyString function checks if provided value is a String and if it's not empty.

In addition, we would like the view to auto-scroll down whenever a new message is added. We can achieve that using a native class called MutationObserver, which can detect changes in the view:

4.20 Add an auto scroller to messages component client/imports/pages/chat/messages-page.component.ts
1
2
3
4
 
15
16
17
18
19
20
21
22
23
24
25
26
27
 
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
 
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import {Component, OnInit, OnDestroy} from "@angular/core";
import {NavParams} from "ionic-angular";
import {Chat} from "../../../../both/models/chat.model";
import {Messages} from "../../../../both/collections/messages.collection";
...some lines skipped...
    style
  ]
})
export class MessagesPage implements OnInit, OnDestroy {
  private selectedChat: Chat;
  private title: string;
  private picture: string;
  private messages: Observable<Message[]>;
  private message = "";
  private autoScroller: MutationObserver;
 
  constructor(navParams: NavParams) {
    this.selectedChat = <Chat>navParams.get('chat');
...some lines skipped...
 
      return messages;
    });
 
    this.autoScroller = this.autoScroll();
  }
 
  ngOnDestroy() {
    this.autoScroller.disconnect();
  }
 
  private get messagesPageContent(): Element {
    return document.querySelector('.messages-page-content');
  }
 
  private get messagesPageFooter(): Element {
    return document.querySelector('.messages-page-footer');
  }
 
  private get messagesList(): Element {
    return this.messagesPageContent.querySelector('.messages');
  }
 
  private get messageEditor(): HTMLInputElement {
    return <HTMLInputElement>this.messagesPageFooter.querySelector('.message-editor');
  }
 
  private get scroller(): Element {
    return this.messagesList.querySelector('.scroll-content');
  }
 
  onInputKeypress({keyCode}: KeyboardEvent): void {
...some lines skipped...
      this.message = '';
    });
  }
 
  autoScroll(): MutationObserver {
    const autoScroller = new MutationObserver(this.scrollDown.bind(this));
 
    autoScroller.observe(this.messagesList, {
      childList: true,
      subtree: true
    });
 
    return autoScroller;
  }
 
  scrollDown(): void {
    this.scroller.scrollTop = this.scroller.scrollHeight;
    this.messageEditor.focus();
  }
}

Why didn't we update the scrolling position on a Meter computation? That's because we want to initiate the scrolling function once the view is ready, not the data. They might look similar, but the difference is crucial.

{{{nav_step prev_ref="https://angular-meteor.com/tutorials/whatsapp2/meteor/1.0.0/meteor-server-side" next_ref="https://angular-meteor.com/tutorials/whatsapp2/meteor/1.0.0/authentication"}}}