/ tutorial

Ionic Socket Chat

Some time ago, I started played around with Ionic. The framework itself was great, but I really disliked Angular. Since then, both Angular and the Ionic framework have come a long way. Now, I find myself needing to use Ionic at work, so I decided to dive in. The ease of use makes it ideal for fast mobile application development. I mean, like really fast. The UI components are easy to use with minimal configuration. I decided to build another real-time chat app, this time leveraging Ionic for the client.

I'm assuming you have some knowledge of Node, npm, etc. Before we get started, go ahead and install Ionic and cordova: npm install -g cordova ionic.

The Server

The first thing we'll be building is our backend. For this app, we're keeping it simple. Messages are not being stored at all, and we're not authenticating users at all. We're just implementing some basic socket functions.

We'll start with making a folder for the project and the server, and creating a basic package.json:

mkdir ionic-socket-chat && cd ionic-socket-chat
mkdir server && cd server
npm init -y

Next, in the server folder, we'll install our dependencies, Express and Socket.io:

npm install --save express socket.io
# --save can be omitted as of npm version 5

Now, we can create the file index.js. Here we'll create a simple Express server. All of our functionality will happen inside of the io.on('connection') block.

const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);

io.on('connection', socket => {
  // All functions will go here
});

const port = process.env.PORT || 3001;

http.listen(port, function() {
  console.log(`Listening at http://localhost:${ port }`);
});

The final thing to do for the server, is to create functions to handle the events set-nickname, add-message, and disconnect. This means our Ionic app will emit these events, and our server will then do something. Let's add those now:

...
io.on('connection', socket => {
  socket.on('set-nickname', nickname => {
    if (nickname) {
      socket.nickname = nickname;
      console.log(`${ socket.nickname } joined`);
      io.emit('users-changed', { user: nickname, event: 'joined' });
    }
  });

  socket.on('add-message', message => {
    console.log(`${ socket.nickname } sent: ${ message.text }`);
    io.emit('message', { text: message.text, from: socket.nickname, created: new Date() });
  });
  
  socket.on('disconnect', function() {
    if (socket.nickname) {
      console.log(`${ socket.nickname } left`);
      io.emit('users-changed', { user: socket.nickname, event: 'left' });
    }
  });
});
...

That's all for the server. You can start it by running node index from inside the server folder.

The Client

As mentioned, the client we're building is going to be an Ionic app. From the project folder (ionic-socket-chat) run the following commands:

ionic start client blank
cd client
npm install ng-socket-io --save
ionic g page chatRoom

This creates a new Ionic project called 'client' using the 'blank' template, then installs Socket.io for use with Angluar (used by Ionic), and generates a new Ionic page, chatRoom.

Next we need to add Socket.io to src/app/app.module.ts, and configure it to use our backend. It should look something like this:

import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';

import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
import { SocketIoModule, SocketIoConfig } from 'ng-socket-io';
const config: SocketIoConfig = { url: 'http://localhost:3001', options: {} };

@NgModule({
  declarations: [
    MyApp,
    HomePage
  ],
  imports: [
    BrowserModule,
    IonicModule.forRoot(MyApp),
    SocketIoModule.forRoot(config),
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler}
  ]
})
export class AppModule {}

Join the Chat

First, our user needs a name to join our chat. This view will contain an input field and a button to join. Change src/pages/home/home.html to this:

<ion-header>
  <ion-navbar>
    <ion-title>
      Join Chat
    </ion-title>
  </ion-navbar>
</ion-header>

<ion-content padding>
  <ion-item>
    <ion-label stacked>Set Nickname</ion-label>
    <ion-input type="text" [(ngModel)]="nickname" placeholder="Nickname"></ion-input>
  </ion-item>
  <button ion-button full (click)="joinChat()" [disabled]="nickname === ''">Join as {{ nickname }}</button>
</ion-content>

Next, we'll connect our home class, we'll set up out first Socket interaction, so the server knows there's a new connection. To do this, we need to add the joinChat() method to src/pages/home/home.ts:

...
nickname = '';

constructor(public navCtrl: NavController, private socket: Socket) { }

joinChat() {
    this.socket.connect();
    this.socket.emit('set-nickname', this.nickname);
    this.navCtrl.push('ChatRoomPage', { nickname: this.nickname });
}
...

At this point, our client is making a connection with the server! Now, we can add the actual chat functionality.

Adding the Chat Functionality

When the Chat Room page is loaded, it is passed the nickname we set on the Home page. We need to capture that parameter and this.nickname to the value provided. In case this page is reloaded, and therefore doesn't have a nickname, we'll also check to make sure the nickname is set, and redirect to the home page if it's not. Update the constructor() method to look like this:

...
nickname = '';

constructor(private navCtrl: NavController, private navParams: NavParams, private socket: Socket, private toastCtrl: ToastController) {
    this.nickname = this.navParams.get('nickname');
    if (!this.nickname) {
      this.navCtrl.setRoot('HomePage');
    }
}
...

To receive messages, we'll create the function getMessages(). This will return an observable. It will also tell Socket to listen for message events, and call next() on the observable to pass the new value through to the page. Whenever we receive a new message, we will push it to an array of messages. As mentioned, we're not storing messages at all. That also means we are not loading historical data. We're only loading messages that are created after we're connected. Add this method to src/pages/chat-room/chat-room.ts:

...
getMessages() {
    let observable = new Observable(observer => {
        this.socket.on('message', (data) => {
            observer.next(data);
        });
    });
    return observable;
}
...

To make use of the observable, we'll create the this.messages array, then call this.getMessages() in our constructor() method:

...
messages = [];

constructor(private navCtrl: NavController, private navParams: NavParams, private socket: Socket, private toastCtrl: ToastController) {
    ...
    this.getMessages().subscribe(message => {
        this.messages.push(message);
    });
}
...

Now, when the server emits the message event, we receive the new message, and push it to our this.messages array. Of course, this can't happen yet, as our app has no way to send messages. For that, we'll create the method sendMessage():

...
message = '';
...

sendMessage() {
    this.socket.emit('add-message', { text: this.message });
    this.message = '';
}
...

We also want our app to notify us when users join or leave the chat room. Like messages, we'll have a function return an observable for users, then show a "toast" when the users-changed event is fired.

constructor(private navCtrl: NavController, private navParams: NavParams, private socket: Socket, private toastCtrl: ToastController) {
    ...
    this.getUsers().subscribe(data => {
        let user = data['user']; 
        if (data['event'] === 'left') {
            this.showToast('User left: ' + user);
        } else {
            this.showToast('User joined: ' + user);
        }
    });
}

getUsers() {
    let observable = new Observable(observer => {
        this.socket.on('users-changed', (data) => {
            observer.next(data);
        });
    });
    return observable;
}

showToast() {
    let toast = this.toastCtrl.create({
        message: msg,
        duration: 2000,
        position: 'top',
    });
    toast.present();
}

That's it for the class. Now, we will create our view. Open src/pages/chat-room/chat-room.html and change it to the following:

<ion-header>
  <ion-navbar>
    <ion-title>Chat</ion-title>
  </ion-navbar>
</ion-header>


<ion-content>
  <ion-grid>
    <ion-row *ngFor="let message of messages">
      <ion-col col-9 class="message" [ngClass]="message.from === nickname ? 'message--mine' : 'message--other'">
        <div class="time">{{message.created | date:'MMM. dd @ hh:MM'}}</div>
        <span class="user_name">{{ message.from }}:</span><br>
        <span>{{ message.text }}</span>
      </ion-col>
    </ion-row>
  </ion-grid>
</ion-content>

<ion-footer>
  <ion-toolbar>
    <ion-row class="message_row">
      <ion-col col-9>
        <ion-item no-lines>
          <ion-input type="text" placeholder="Message" [(ngModel)]="message"></ion-input>
        </ion-item>
      </ion-col>
      <ion-col col-3>
        <button ion-button clear color="primary" (click)="sendMessage()" [disabled]="message === ''">
        Send
      </button>
      </ion-col>
    </ion-row>
  </ion-toolbar>
</ion-footer>

This is a pretty straight forward Ionic view. We have our ion-header which has a navbar with our title. Because we push() this view, the navbar will have a "Back" button added by Ionic. In ion-content we output each of the message in our messages array, using *ngFor on our ion-row. We are also conditionally setting the class on ion-col based on whether the message is our own, or from another user. Finally, ion-footer and ion-toolbar hold out message input and the "Send" button. You can see that we're calling sendMessage() when the button is clicked.

Style It

For the most part, Ionic UI elements are styled exactly how we want/need for our app. In fact, the Home Page view isn't getting any additional styling. The chat room, however, needs just a little bit of styling to achieve the look we're after. Open up src/pages/chat-room/chat-room.scss and replace the contents with the following:

page-chat-room {
    .user_name {
        color: #2a2a2a;
    }
    .message {
        padding: 10px !important;
        transition: all 250ms ease-in-out !important;
        border-radius: 10px !important;
        margin-top: 18px !important;
        margin-bottom: 4px !important;
    }
    .message--mine {
        margin-left: 25%;
        background: color($colors, primary) !important;
        color: #fff !important;

        .time {
            right: 0;
        }
    }
    .message--other {
        background: #dcdcdc !important;
        color: #000 !important;

        .time {
            left: 0;
        }
    }

    .time {
        color: #afafaf;
        position: absolute;
        top: -18px;
        font-size: small;
    }
    
    .message_row {
        background-color: #fff;
    }
}

Now, our app should look something like this:

Home Page
Chat Page

Wrap up

That's it! At this point you should have a functioning application. You'll need two terminal windows to run it. In one, go to ~/ionic-socket-chat/server and run node index.js. In the other, go to ~/ionic-socket-chat/client and run ionic serve. Your browser will open to the app automatically. To really test the app, open an Incognito window and point it to the app at http://localhost:8100/. In each window, type a nickname and chat with yourself.

If you want to run the app on different computers or devices, you'll need to change the Socket config in src/app/app.module.ts to use your IP address rather than localhost.