Fork me on GitHub

WhatsApp Clone with Meteor and Ionic 2 CLI

Legacy Tutorial Version (Last Update: 22.11.2016)

Realtime Meteor Server

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

Meteor Client Side package

We want to have Meteor essentials available in our client so we can interface with our Meteor server.

We're gonna install a package called meteor-client-side which is gonna provide us with them:

$ npm install --save meteor-client-side

And let's import it into our project, in the src/app/main.ts file:

3.2 Import meteor client side into the project src/app/main.ts
1
2
3
4
5
import 'meteor-client-side';
 
import { platformBrowserDynamic } from [email protected]/platform-browser-dynamic';
 
import { AppModule } from './app.module';

By default, our Meteor client will try to connect to localhost:3000. If you'd like to change that, add the following script tag in your index.html:

<script>
    (function() {
      __meteor_runtime_config__ = {
        // Your server's IP address goes here
        DDP_DEFAULT_CONNECTION_URL: "http://api.server.com"
      };
    })();
</script>

More information can be found here: https://github.com/idanwe/meteor-client-side

Meteor Server

Now that we have the initial chats layout and its component, we will take it a step further by providing the chats data from a server instead of having it locally. In this step we will be implementing the API server and we will do so using Meteor.

First make sure that you have Meteor installed. If not, install it by typing the following command:

$ curl https://install.meteor.com/ | sh

We will start by creating the Meteor project which will be placed inside the api dir:

$ meteor create api

NOTE: Despite our decision to stick to Ionic's CLI, there is no other way to create a proper Meteor project except for using its CLI.

Let's start by removing the client side from the base Meteor project.

A Meteor project will contain the following dirs by default:

  • client - A dir containing all client scripts.
  • server - A dir containing all server scripts.

These scripts should be loaded automatically by their alphabetic order on their belonging platform, e.g. a script defined under the client dir should be loaded by Meteor only on the client. A script defined in neither of these folders should be loaded on both. Since we're using Ionic's CLI for the client code we have no need in the client dir in the Meteor project. Let's get rid of it:

api$ rm -rf client

We also want to make sure that node modules are accessible from both client and server. To fulfill it, we gonna create a symbolic link in the api dir which will reference to the project's root node modules:

api$ ln -s ../node_modules

And remove its ignore rule:

3.5 Add a symbolic link to node_modules in api api/node_modules
1
../node_modules

Now that we share the same resource there is no need in two package.json dependencies specifications, so we can just remove it:

api$ rm package.json

Since we will be writing our app using Typescript also in the server side, we will need to support it in our Meteor project as well, especially when the client and the server share some of the script files. To add this support let's add the following package to our Meteor project:

api$ meteor add barbatus:typescript

Because we use TypeScript, let's change the main server file extension from .js to .ts:

api$ mv server/main.js server/main.ts

We will also need to add a configuration file for the TypeScript compiler in the Meteor server, which is based on our Ionic app's config:

3.9 Created tsconfig.json api/tsconfig.json
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
{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "declaration": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": [
      "dom",
      "es2015"
    ],
    "module": "commonjs",
    "moduleResolution": "node",
    "sourceMap": true,
    "target": "es5",
    "skipLibCheck": true,
    "stripInternal": true,
    "noImplicitAny": false
  },
  "exclude": [
    "node_modules"
  ],
  "files": [
    "declarations.d.ts"
  ],
  "compileOnSave": false,
  "atom": {
    "rewriteTsconfig": false
  }
}

Now we will need to create a symbolic link to the declaration file located in src/declarations.d.ts. This way we can share external TypeScript declarations in both client and server. To create the desired symbolic link, simply type the following command in the command line:

$ api ln -s ../src/declarations.d.ts

Once we've created the symbolic link we can go ahead and add the missing declarations so our Meteor project can function properly using the TypeScript compiler:

3.10 Created TypeScript typings file api/declarations.d.ts
1
../src/declarations.d.ts
3.10 Created TypeScript typings file src/declarations.d.ts
11
12
13
14
15
16
17
18
  For more info on type definition files, check out the Typescript docs here:
  https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html
*/
/// <reference types="meteor-typings" />
/// <reference types="@types/underscore" />
/// <reference path="../models/whatsapp-models.d.ts" />
declare module '*';
 

The following dependencies are required to be installed so our server can function properly:

$ npm install --save @types/meteor
$ npm install --save @types/underscore
$ npm install --save babel-runtime
$ npm install --save meteor-node-stubs
$ npm install --save meteor-rxjs
$ npm install --save meteor-typings

Now we'll have to move our models interfaces to the api dir so the server will have access to them as well:

$ mv models/whatsapp-models.d.ts api/whatsapp-models.d.ts

This requires us to update its reference in the declarations file as well:

3.12 Updated import path src/declarations.d.ts
13
14
15
16
17
18
*/
/// <reference types="meteor-typings" />
/// <reference types="@types/underscore" />
/// <reference path="../api/models/whatsapp-models.d.ts" />
declare module '*';
 

Collections

In Meteor, we keep data inside Mongo.Collections.

This collection is actually a reference to a MongoDB collection, and it is provided to us by a Meteor package called Minimongo, and it shares almost the same API as a native MongoDB collection. In this tutorial we will be wrapping our collections using RxJS's Observables, which is available to us thanks to meteor-rxjs.

Let's create a chats and messages collection, which will be used to store data related to newly created chats and written messages:

3.13 Created Collections api/collections/whatsapp-collections.ts
1
2
3
4
import { MongoObservable } from "meteor-rxjs";
 
export const Chats = new MongoObservable.Collection("chats");
export const Messages = new MongoObservable.Collection("messages");

Data fixtures

Since we have real collections now, and not dummy ones, we will need to fill them up with some initial data so we will have something to test our application against to. Let's create our data fixtures in the server:

3.14 Added stub data to the collection in the server side api/server/main.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
import * as moment from "moment";
import { Meteor } from 'meteor/meteor';
import { Chats, Messages } from "../collections/whatsapp-collections";
 
Meteor.startup(() => {
  if (Chats.find({}).cursor.count() === 0) {
    let chatId;
 
    chatId = Chats.collection.insert({
      title: 'Ethan Gonzalez',
      picture: 'https://randomuser.me/api/portraits/thumb/men/1.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'You on your way?',
      createdAt: moment().subtract(1, 'hours').toDate()
    });
 
    chatId = Chats.collection.insert({
      title: 'Bryan Wallace',
      picture: 'https://randomuser.me/api/portraits/thumb/lego/1.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'Hey, it\'s me',
      createdAt: moment().subtract(2, 'hours').toDate()
    });
 
    chatId = Chats.collection.insert({
      title: 'Avery Stewart',
      picture: 'https://randomuser.me/api/portraits/thumb/women/1.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'I should buy a boat',
      createdAt: moment().subtract(1, 'days').toDate()
    });
 
    chatId = Chats.collection.insert({
      title: 'Katie Peterson',
      picture: 'https://randomuser.me/api/portraits/thumb/women/2.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'Look at my mukluks!',
      createdAt: moment().subtract(4, 'days').toDate()
    });
 
    chatId = Chats.collection.insert({
      title: 'Ray Edwards',
      picture: 'https://randomuser.me/api/portraits/thumb/men/2.jpg'
    });
 
    Messages.collection.insert({
      chatId: chatId,
      content: 'This is wicked good ice cream.',
      createdAt: moment().subtract(2, 'weeks').toDate()
    });
  }
});

Here's a quick overview: We use .collection to get the actual Mongo.Collection instance, this way we avoid using Observables. At the beginning we check if Chats Collection is empty by using .count() operator. Then we provide few chats with one message each. We also bundled a message along with a chat using its id.

UI

Since Meteor's API requires us to share some of the code in both client and server, we have to import all the collections on the client-side too. Let's use the collections in the chats component:

3.16 Added the chats with the last message using RxJS operators src/pages/chats/chats.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
import { Component, OnInit } from [email protected]/core';
import { Observable } from "rxjs";
import { Chat } from "api/models/whatsapp-models";
import { Chats, Messages } from "api/collections/whatsapp-collections";
 
@Component({
  templateUrl: 'chats.html'
})
export class ChatsPage implements OnInit {
  chats;
 
  constructor() {
 
  }
 
  ngOnInit() {
    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;
              })
          )
        )
      ).zone();
  }
 
  removeChat(chat: Chat): void {

As you can see, we moved the chats's property initialization to ngOnInit, one of Angular's lifehooks. It's being called when the component is initialized.

I'd also like to point something regards RxJS. Since Chats.find() returns an Observable we can take advantage of that and bundle it with Messages.find() to look for the last messages of each chat. This way everything will work as a single unit. Let's dive into RxJS's internals to have a deeper understanding of the process.

Find chats

First thing is to get all the chats by using Chats.find({}). The result of it will be an array of Chat objects. Let's use the map to reserve the lastMessage property in each chat:

Chats.find({})
    .map(chats => {
        const chatsWithMessages = chats.map(chat => {
            chat.lastMessage = undefined;
            return chat;
        });

        return chatsWithMessages;
    })

Look for the last message

For each chat we need to find the last message. We can achieve that by calling Messages.find with proper selector and options.

Let's go through each element of the chats property to call Messages.find:

const chatsWithMessages = chats.map(chat => Messages.find(/* selector, options*/));

This should return an array of Observables. Our selector would consist of a query which looks for a message that is a part of the required chat:

{
    chatId: chat._id
}

Since we're only interested the last message, we will be using the sort option based on the createdAt field:

{
    sort: {
        createdAt: -1
    }
}

This way we get all the messages sorted from newest to oldest. Since we're only interested in one, we will be limiting our result set:

{
    sort: {
        createdAt: -1
    },
    limit: 1
}

Now we can add the last message to the chat:

Messages.find(/*...*/)
    .map(messages => {
        if (messages) chat.lastMessage = messages[0];
        return chat;
    })

Great! But what if there aren't any messages? Wouldn't it emit a value at all? RxJS contains an operator called startWith. It allows us to emit an initial value before we map our messages. This way we avoid the waiting for non existing message:

const chatsWithMessages = chats.map(chat => {
    return Messages.find(/*...*/)
        .startWith(null)
        .map(messages => {
            if (messages) chat.lastMessage = messages[0];
            return chat;
        })
})

Combine these two

Last thing to do would be handling the array of Observables we created (chatsWithMessages).

Yet again, RxJS comes with a rescue. We will use combineLatest which takes few Observables and combines them into a single one.

Here's a quick example:

const source1 = /* an Observable */
const source2 = /* an Observable */

const result = Observable.combineLatest(source1, source2);

This combination returns an array of both results (result). So the first item of that array will come from source1 (result[0]), second from source2 (result[1]).

Let's see how it applies to our app:

Observable.combineLatest(...chatsWithMessages);

We used ...array because Observable.combineLatest expects to be invoked with chained arguments, not a single array of Observables.

To merge that observable into Chats.find({}) we need to use mergeMap operator instead of map:

Chats.find({})
    .mergeMap(chats => Observable.combineLatest(...chatsWithMessages));

In our app we used chats.map(/*...*/) directly instead of creating another variables like we did with chatsWithMessages.

By now we should have a data-set which consists of a bunch of chats, and each should have its last message defined on it.

To run our Meteor server, simply type the following command in the api dir:

api$ meteor

Now you can go ahead and test our application against the server.

{{{nav_step prev_ref="https://angular-meteor.com/tutorials/whatsapp2/ionic/1.0.0/chats-page" next_ref="https://angular-meteor.com/tutorials/whatsapp2/ionic/1.0.0/messages-page"}}}