Fork me on GitHub

WhatsApp Clone with Meteor and Ionic CLI

Chat view and send messages

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

We still don’t have an identity for each user, we will add it later, but we can still send messages to existing chats.

So just like any other page, let’s begin by adding a very basic view with the chat's details:

4.1 Add chat view www/templates/chat.html
1
2
3
4
5
<ion-view title="{{ chat.data.name }}">
  <ion-nav-buttons side="right">
    <button class="button button-clear"><img class="header-picture" ng-src="{{ chat.data.picture }}"></button>
  </ion-nav-buttons>
</ion-view>

Now we need to implement the logic in the controller, so let’s create it:

4.2 Add chat controller src/controllers/chat.controller.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Chats } from 'api/collections';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class ChatCtrl extends Controller {
  static $inject = ['$stateParams']
 
  constructor() {
    super(...arguments);
 
    this.chatId = this.$stateParams.chatId;
 
    this.helpers({
      data() {
        return Chats.findOne(this.chatId);
      }
    });
  }
}
 
ChatCtrl.$name = 'ChatCtrl';
4.3 Load chat controller src/app.js
4
5
6
7
8
9
10
 
17
18
19
20
21
22
23
import StatusBar from 'cordova/status-bar';
import Loader from 'angular-ecmascript/module-loader';
 
import ChatCtrl from './controllers/chat.controller';
import ChatsCtrl from './controllers/chats.controller';
import CalendarFilter from './filters/calendar.filter';
import RoutesConfig from './routes';
...some lines skipped...
]);
 
new Loader(App)
  .load(ChatCtrl)
  .load(ChatsCtrl)
  .load(CalendarFilter)
  .load(RoutesConfig);

We used the $statePrams provider to get the id of the chat, and then we used the Chats collection to find the data related to the it. The function findOne() takes a query as a parameter and returns a single document. Just like collections in MongoDB.

Now that we have the view and the controller let's connect them by adding the appropriate route state:

4.4 Add chat route state src/routes.js
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
            controller: 'ChatsCtrl as chats'
          }
        }
      })
      .state('tab.chat', {
        url: '/chats/:chatId',
        views: {
          'tab-chats': {
            templateUrl: 'templates/chat.html',
            controller: 'ChatCtrl as chat'
          }
        }
      });
 
    this.$urlRouterProvider.otherwise('tab/chats');

And all is left to do is to link these two:

4.5 Add chat reference to chats view www/templates/chats.html
3
4
5
6
7
8
9
10
    <ion-list>
      <ion-item ng-repeat="chat in chats.data | orderBy:'-lastMessage.timestamp'"
                class="item-chat item-remove-animate item-avatar item-icon-right"
                type="item-text-wrap"
                href="#/tab/chats/{{ chat._id }}">
        <img ng-src="{{ chat.picture }}">
        <h2>{{ chat.name }}</h2>
        <p>{{ chat.lastMessage.text }}</p>

Now each time we will click on a chat item from the menu, we should be navigating to it.

Let’s create a new scss file to our Chat and fix the image style so it won't look silly:

4.6 Add chat stylesheet scss/chat.scss
1
2
3
4
5
6
7
.header-picture {
  max-width: 33px;
  max-height: 33px;
  width: 100%;
  height: 100%;
  border-radius: 50%;
}
4.7 Import chat stylesheet scss/ionic.app.scss
21
22
23
24
25
// Include all of Ionic
@import "www/lib/ionic/scss/ionic";
 
@import "chat";
@import "chats";

Our next step is about getting the chat messages on the controller, we will add another helper, but instead of using the whole collection we will fetch only the relevant messages for the current chat:

4.8 Add messages helper to chat controller src/controllers/chat.controller.js
1
2
3
4
 
10
11
12
13
14
15
16
17
18
import { Chats, Messages } from 'api/collections';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class ChatCtrl extends Controller {
...some lines skipped...
    this.chatId = this.$stateParams.chatId;
 
    this.helpers({
      messages() {
        return Messages.find({ chatId: this.chatId });
      },
      data() {
        return Chats.findOne(this.chatId);
      }

And now to add it to the view, we use ng-repeat to iterate the messages:

4.9 Add messages to chat view www/templates/chat.html
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  <ion-nav-buttons side="right">
    <button class="button button-clear"><img class="header-picture" ng-src="{{ chat.data.picture }}"></button>
  </ion-nav-buttons>
 
  <ion-content class="chat" delegate-handle="chatScroll">
    <div class="message-list">
      <div ng-repeat="message in chat.messages" class="message-wrapper">
        <div class="message" ng-class-even="'message-mine'" ng-class-odd="'message-other'">
          <div class="message-text">{{ message.text }}</div>
          <span class="message-timestamp">{{ message.timestamp }}</span>
        </div>
      </div>
    </div>
  </ion-content>
</ion-view>

Now that it is well functioning, let's polish our Chats's looking by adding some style to our newly created messages:

4.10 Add message style to chat stylesheet scss/chat.scss
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
  width: 100%;
  height: 100%;
  border-radius: 50%;
}
 
.chat {
  background-image: url(/img/chat-background.jpg);
  background-color: #E0DAD6;
  background-repeat: no-repeat;
  background-size: 100%;
}
 
.message-list {
  margin-top: 12px;
  padding: 0 5%;
}
 
.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;
  }
 
  &.message-other {
    float: left;
    background-color: #FFF;
  }
 
  &.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-other::before {
    left: -11px;
    background-image: url(/img/message-other.png)
  }
 
  &.message-mine::before {
    right: -11px;
    background-image: url(/img/message-mine.png)
  }
 
  .message-text {
    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;
    color: gray;
    font-size: 12px;
  }
}

Also, this stylesheet uses some assets located in the www/img dir, so inorder for the stylesheet to work properly you'll need to copy the files located here.

After doing so, our app should look like this:

{{tutorialImage 'ionic' '4.png' 500}}

Now we just need to take care of the message timestamp and format it.

We will use Moment like before, but now let's add another package called angular-moment that provides us the UI filters.

So adding the package is just like any other package we added so far. First, we will install it:

$ npm install angular-moment --save
4.12 Install angular-moment npm package package.json
5
6
7
8
9
10
11
  "dependencies": {
    "angular-ecmascript": "0.0.3",
    "angular-meteor": "^1.3.11",
    "angular-moment": "^1.0.0",
    "babel": "^6.5.2",
    "babel-core": "^6.7.6",
    "babel-loader": "^6.2.4",

And then we will load it:

4.13 Import angular-moment npm package in index js src/index.js
9
10
11
12
13
14
import 'script!lib/ionic/js/ionic-angular';
import 'script!meteor-client-side/dist/meteor-client-side.bundle';
import 'script!angular-meteor/dist/angular-meteor.bundle';
import 'script!angular-moment/angular-moment';
// app
import './app';
4.14 Add angular-moment module to app dependencies src/app.js
13
14
15
16
17
18
19
 
Angular.module(App, [
  'angular-meteor',
  'angularMoment',
  'ionic'
]);
 

NOTE: Because it’s an Angular extension, we loaded its dependency in our module definition.

Now that we have angular-moment ready to use, we will use a filter provided by it in our view:

4.15 Add date format filter to chat view www/templates/chat.html
8
9
10
11
12
13
14
      <div ng-repeat="message in chat.messages" class="message-wrapper">
        <div class="message" ng-class-even="'message-mine'" ng-class-odd="'message-other'">
          <div class="message-text">{{ message.text }}</div>
          <span class="message-timestamp">{{ message.timestamp | amDateFormat: 'HH:MM' }}</span>
        </div>
      </div>
    </div>

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 some icons for sending images and sound recordings, whom logic won't be implemented in this tutorial since we only wanna focus on the messaging system.

The ionic-footer-bar directive provides a perfect solution for placing stuff under our content, let's use it:

4.16 Add message editor to chat view www/templates/chat.html
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
      </div>
    </div>
  </ion-content>
 
  <ion-footer-bar keyboard-attach class="bar-stable footer-chat item-input-inset">
    <button class="button button-clear button-icon button-positive icon ion-ios-upload-outline"></button>
 
    <label class="item-input-wrapper">
      <input ng-model="chat.message"
             dir="auto"
             type="text"/>
    </label>
 
    <span ng-if="chat.message.length > 0">
      <button ng-click="chat.sendMessage()" class="button button-clear button-positive">Send</button>
    </span>
 
    <span ng-if="!chat.message || chat.message.length === 0">
      <button class="button button-clear button-icon button-positive icon ion-ios-camera-outline"></button>
      <i class="buttons-seperator icon ion-android-more-vertical"></i>
      <button class="button button-clear button-icon button-positive icon ion-ios-mic-outline"></button>
    </span>
  </ion-footer-bar>
</ion-view>

To improve the user experience in our app, we want some extra events to our input because we want to move it up when the keyboard comes from the bottom of the screen and we want to know if return (aka Enter) was pressed.

We will implement a new directive that extends the regular input tag and add those events to the directive:

4.17 Add input directive src/directives/input.directive.js
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
import { Directive } from 'angular-ecmascript/module-helpers';
 
export default class InputDirective extends Directive {
  static $inject = ['$timeout']
  static $name = 'input'
 
  restrict = 'E'
 
  scope = {
    'returnClose': '=',
    'onReturn': '&',
    'onFocus': '&',
    'onBlur': '&'
  }
 
  link(scope, element) {
    element.bind('focus', (e) => {
      if (!scope.onFocus) return;
 
      this.$timeout(() => {
        scope.onFocus();
      });
    });
 
    element.bind('blur', (e) => {
      if (!scope.onBlur) return;
 
      this.$timeout(() => {
        scope.onBlur();
      });
    });
 
    element.bind('keydown', (e) => {
      if (e.which != 13) return;
 
      if (scope.returnClose) {
        element[0].blur();
      }
 
      if (scope.onReturn) {
        this.$timeout(() => {
          scope.onReturn();
        });
      }
    });
  }
}
4.18 Load input directive src/app.js
6
7
8
9
10
11
12
 
21
22
23
24
25
26
27
 
import ChatCtrl from './controllers/chat.controller';
import ChatsCtrl from './controllers/chats.controller';
import InputDirective from './directives/input.directive';
import CalendarFilter from './filters/calendar.filter';
import RoutesConfig from './routes';
 
...some lines skipped...
new Loader(App)
  .load(ChatCtrl)
  .load(ChatsCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
  .load(RoutesConfig);
 

And now we can use those events in our view:

4.19 Use input directive events in chat view www/templates/chat.html
20
21
22
23
24
25
26
27
28
29
    <label class="item-input-wrapper">
      <input ng-model="chat.message"
             dir="auto"
             type="text"
             on-return="chat.sendMessage(); chat.closeKeyboard()"
             on-focus="chat.inputUp()"
             on-blur="chat.inputDown()"/>
    </label>
 
    <span ng-if="chat.message.length > 0">

And implement the controller methods which handle those events:

4.20 Implement input events in chat controller src/controllers/chat.controller.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 
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
import Ionic from 'ionic';
import Keyboard from 'cordova/keyboard';
import { Chats, Messages } from 'api/collections';
import { Controller } from 'angular-ecmascript/module-helpers';
 
export default class ChatCtrl extends Controller {
  static $inject = ['$stateParams', '$timeout', '$ionicScrollDelegate']
 
  constructor() {
    super(...arguments);
 
    this.chatId = this.$stateParams.chatId;
    this.isIOS = Ionic.Platform.isWebView() && Ionic.Platform.isIOS();
 
    this.helpers({
      messages() {
...some lines skipped...
      }
    });
  }
 
  inputUp () {
    if (this.isIOS) {
      this.keyboardHeight = 216;
    }
 
    this.scrollBottom(true);
  }
 
  inputDown () {
    if (this.isIOS) {
      this.keyboardHeight = 0;
    }
 
    this.$ionicScrollDelegate.$getByHandle('chatScroll').resize();
  }
 
  closeKeyboard() {
    if (Keyboard) {
      Keyboard.close();
    }
  }
 
  scrollBottom(animate) {
    this.$timeout(() => {
      this.$ionicScrollDelegate.$getByHandle('chatScroll').scrollBottom(animate);
    }, 300);
  }
}
 
ChatCtrl.$name = 'ChatCtrl';

We will also add some css to this view:

4.21 Add footer style to chat stylesheet scss/chat.scss
45
46
47
48
49
50
51
 
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
    background-color: #FFF;
  }
 
  &.message-other::before, &.message-mine::before {
    content: "";
    position: absolute;
    bottom: 3px;
...some lines skipped...
    color: gray;
    font-size: 12px;
  }
}
 
.footer-chat {
  .item-input-wrapper {
    background-color: #FFF;
  }
 
  .button.button-icon {
    margin: 0 10px;
  }
 
  .buttons-seperator {
    color: gray;
    font-size: 18px;
    line-height: 32px;
  }
}

So now when the user focuses on the input, it should pop up.

This is what we got so far:

{{tutorialImage 'ionic' '5.png' 500}}

So now it’s time to implement the sendMessage() in our controller, which is responsible for the logic of sending a message.

We will use Scope.callMethod() in order to call that method on the server side:

4.22 Implement send message method in chat controller src/controllers/chat.controller.js
1
2
3
4
5
6
 
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import Ionic from 'ionic';
import Keyboard from 'cordova/keyboard';
import { _ } from 'meteor/underscore';
import { Chats, Messages } from 'api/collections';
import { Controller } from 'angular-ecmascript/module-helpers';
 
...some lines skipped...
    });
  }
 
  sendMessage() {
    if (_.isEmpty(this.message)) return;
 
    this.callMethod('newMessage', {
      text: this.message,
      chatId: this.chatId
    });
 
    delete this.message;
  }
 
  inputUp () {
    if (this.isIOS) {
      this.keyboardHeight = 216;

Now let’s create our api method in a file called methods.js:

4.23 Add new message method to api api/server/methods.js
1
2
3
4
5
6
7
8
9
10
11
12
13
import { Meteor } from 'meteor/meteor';
import { Chats, Messages } from './collections';
 
Meteor.methods({
  newMessage(message) {
    message.timestamp = new Date();
 
    const messageId = Messages.insert(message);
    Chats.update(message.chatId, { $set: { lastMessage: message } });
 
    return messageId;
  }
});

And we also need to load them in our client, since they are called twice, once in our client (As a validation and smoother experience without refreshing) and once in our server (For security and data handling):

4.24 Import api methods in index js src/index.js
10
11
12
13
14
15
16
import 'script!meteor-client-side/dist/meteor-client-side.bundle';
import 'script!angular-meteor/dist/angular-meteor.bundle';
import 'script!angular-moment/angular-moment';
// api
import 'api/methods';
// app
import './app';

We would also like to validate some data sent to methods we define. Meteor provides us with a useful package named check that validates data types and scheme.

We will add it to our server using the following commands:

$ cd api
$ meteor add check

NOTE: meteor-client-side is already provided with the check package so no need to require it again.

Now let’s use it in the newMessage() method:

4.26 Add validation to new message method api/server/methods.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { Chats, Messages } from './collections';
 
Meteor.methods({
  newMessage(message) {
    check(message, {
      text: String,
      chatId: String
    });
 
    message.timestamp = new Date();
 
    const messageId = Messages.insert(message);

Now that it's ready you can go ahead and send a message and view it on the screen. It should look like this: