Fork me on GitHub

WhatsApp Clone with Meteor CLI and Ionic

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, first we need to add a route and a state.

Let's call it chat and we will load a template and a controller which we will add later.

3.1 Add chat route state client/scripts/routes.js
1
2
3
4
5
6
7
 
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { Config } from 'angular-ecmascript/module-helpers';
 
import chatsTemplateUrl from '../templates/chats.html';
import chatTemplateUrl from '../templates/chat.html';
import tabsTemplateUrl from '../templates/tabs.html';
 
export default class RoutesConfig extends Config {
...some lines skipped...
            controller: 'ChatsCtrl as chats'
          }
        }
      })
      .state('tab.chat', {
        url: '/chats/:chatId',
        views: {
          'tab-chats': {
            templateUrl: chatTemplateUrl,
            controller: 'ChatCtrl as chat'
          }
        }
      });
 
    this.$urlRouterProvider.otherwise('tab/chats');

Let's add a very basic view with the chat's details. The file will be located in client/templates/chat.html:

3.2 Add chat view client/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 in client/scripts/controllers/chat.controller.js and call it ChatCtrl.

We will use the $stateParams provider to get the chat id and then we will define a helper that will help us fetch the chat that we want.

So in order to do that we shall define a helper named chat, and use findOne() to fetch the wanted document.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Controller } from 'angular-ecmascript/module-helpers';
import { Chats } from '../../../lib/collections';
 
export default class ChatCtrl extends Controller {
  constructor() {
    super(...arguments);
 
    this.chatId = this.$stateParams.chatId;
 
    this.helpers({
      data() {
        return Chats.findOne(this.chatId);
      }
    });
  }
}
 
ChatCtrl.$name = 'ChatCtrl';
ChatCtrl.$inject = ['$stateParams'];
3.4 Load chat controller client/scripts/lib/app.js
10
11
12
13
14
15
16
 
24
25
26
27
28
29
30
 
// Modules
import ChatsCtrl from '../controllers/chats.controller';
import ChatCtrl from '../controllers/chat.controller';
import CalendarFilter from '../filters/calendar.filter';
import RoutesConfig from '../routes';
 
...some lines skipped...
 
new Loader(App)
  .load(ChatsCtrl)
  .load(ChatCtrl)
  .load(CalendarFilter)
  .load(RoutesConfig);
 

So now that we have the chat view and controller, all is left to do is to link these two:

3.5 Add reference to chat view in chats view client/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 let's add some css rules and let's add the messages view.

Let's create a new sass file for our view at client/styles/chat.scss, and fix the image style so it won't look silly:

3.6 Add chat stylesheet client/styles/chat.scss
1
2
3
4
5
6
7
.header-picture {
  max-width: 33px;
  max-height: 33px;
  width: 100%;
  height: 100%;
  border-radius: 50%;
}

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

3.7 Add messages helper to chat controller client/scripts/controllers/chat.controller.js
1
2
3
4
5
 
8
9
10
11
12
13
14
15
16
import { Controller } from 'angular-ecmascript/module-helpers';
import { Chats, Messages } from '../../../lib/collections';
 
export default class ChatCtrl extends Controller {
  constructor() {
...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:

3.8 Add messages to chat view client/templates/chat.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<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-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>

As for now we do not have an identity for each user or message, so we will just use odd/even classes and this will be the indication for which message is mine and which isn't. In the next step we will add the authentication and each message will be related to a user.

Now we will add some css to the messages list:

3.9 Add message style to chat stylesheet client/styles/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(/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(/message-other.png)
  }
 
  &.message-mine::before {
    right: -11px;
    background-image: url(/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;
  }
}

We also need to add some Whatsapp assets so it would look more similar.

Note that the images are under public/ folder so we can use them in the client side from the root directory (in the css file).

You can copy them from here.

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:

$ meteor npm install angular-moment --save

And since it's an AngularJS extension, we need to add a dependency in our module definition:

3.12 Load angular-moment client/scripts/lib/app.js
1
2
3
4
5
6
7
 
20
21
22
23
24
25
26
// Libs
import 'angular-animate';
import 'angular-meteor';
import 'angular-moment';
import 'angular-sanitize';
import 'angular-ui-router';
import 'ionic-scripts';
...some lines skipped...
// App
Angular.module(App, [
  'angular-meteor',
  'angularMoment',
  'ionic'
]);
 

And now we will use a filter from this package in our view:

3.13 Add moment filter to chat view client/templates/chat.html
7
8
9
10
11
12
13
      <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>

Just like Whatsapp...

Our next step is about adding the input for adding a new message to the chat, we need to add an input at the bottom of the view. ion-footer-bar provides a perfect solution for that.

So we will add an input, a send button and some icons for sending images and sound recordings (For now we will live them just so our view would look reach without any logic implemented behind).

3.14 Add chatbox to chat view client/templates/chat.html
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
      </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>

Let's add the data object to our controller, and add a stub method for sendMessage(), which will be implemented further in this tutorial.

3.15 Add chatbox logic to chat controller client/scripts/controllers/chat.controller.js
16
17
18
19
20
21
22
23
24
      }
    });
  }
 
  sendMessage() {
  }
}
 
ChatCtrl.$name = 'ChatCtrl';

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 the return button (aka Enter) was pressed.

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

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
import { Directive } from 'angular-ecmascript/module-helpers';
 
export default class InputDirective extends Directive {
  constructor() {
    super(...arguments);
 
    this.restrict = 'E';
 
    this.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();
        });
      }
    });
  }
}
 
InputDirective.$name = 'input';
InputDirective.$inject = ['$timeout'];
3.17 Load input directive client/scripts/lib/app.js
12
13
14
15
16
17
18
 
28
29
30
31
32
33
34
// Modules
import ChatsCtrl from '../controllers/chats.controller';
import ChatCtrl from '../controllers/chat.controller';
import InputDirective from '../directives/input.directive';
import CalendarFilter from '../filters/calendar.filter';
import RoutesConfig from '../routes';
 
...some lines skipped...
new Loader(App)
  .load(ChatsCtrl)
  .load(ChatCtrl)
  .load(InputDirective)
  .load(CalendarFilter)
  .load(RoutesConfig);
 

And now we can use those events in our view:

3.18 Add chat view client/templates/chat.html
18
19
20
21
22
23
24
25
26
27
    <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:

3.19 Implement input directive handlers in chat controller client/scripts/controllers/chat.controller.js
1
2
3
4
5
 
8
9
10
11
12
13
14
15
 
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 Ionic from 'ionic-scripts';
import { Meteor } from 'meteor/meteor';
import { Controller } from 'angular-ecmascript/module-helpers';
import { Chats, Messages } from '../../../lib/collections';
 
...some lines skipped...
    super(...arguments);
 
    this.chatId = this.$stateParams.chatId;
    this.isIOS = Ionic.Platform.isWebView() && Ionic.Platform.isIOS();
    this.isCordova = Meteor.isCordova;
 
    this.helpers({
      messages() {
...some lines skipped...
 
  sendMessage() {
  }
 
  inputUp () {
    if (this.isIOS) {
      this.keyboardHeight = 216;
    }
 
    this.scrollBottom(true);
  }
 
  inputDown () {
    if (this.isIOS) {
      this.keyboardHeight = 0;
    }
 
    this.$ionicScrollDelegate.$getByHandle('chatScroll').resize();
  }
 
  closeKeyboard () {
    if (this.isCordova) {
      cordova.plugins.Keyboard.close();
    }
  }
 
  scrollBottom(animate) {
    this.$timeout(() => {
      this.$ionicScrollDelegate.$getByHandle('chatScroll').scrollBottom(animate);
    }, 300);
  }
}
 
ChatCtrl.$name = 'ChatCtrl';
ChatCtrl.$inject = ['$stateParams', '$timeout', '$ionicScrollDelegate'];

We will also add some css to this view:

3.20 Add footer style to chat stylesheet client/styles/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 goes up.

So now it's time to implement the sendMessage() in our controller.

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

3.21 Implement new message method in chat controller client/scripts/controllers/chat.controller.js
1
2
3
4
5
 
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import Ionic from 'ionic-scripts';
import { _ } from 'meteor/underscore';
import { Meteor } from 'meteor/meteor';
import { Controller } from 'angular-ecmascript/module-helpers';
import { Chats, Messages } from '../../../lib/collections';
...some lines skipped...
  }
 
  sendMessage() {
    if (_.isEmpty(this.message)) return;
 
    this.callMethod('newMessage', {
      text: this.message,
      type: 'text',
      chatId: this.chatId
    });
 
    delete this.message;
  }
 
  inputUp () {

Now let's create our method in lib/methods.js:

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

Let's add validation to our method.

Meteor provides us with a useful package named check that validates data types and scheme.

Add it by running:

$ meteor add check

And now let's use it in the newMessage() method:

3.24 Validate new message method params lib/methods.js
3
4
5
6
7
8
9
10
11
12
13
14
 
Meteor.methods({
  newMessage(message) {
    check(message, {
      type: String,
      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.