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
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:
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
:
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
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:
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:
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:
2
3
4
5
6
7
_id?: string;
chatId?: string;
content?: string;
ownership?: string;
createdAt?: Date;
}
One thing missing, the template:
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:
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;
}
}
}
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:
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;
}
}
}
1
2
3
4
5
.messages-page-content {
> .scroll-content {
margin: 42px -15px 99px !important;
}
}
Now we can add it to the component:
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:
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:
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:
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:
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:
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:
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:
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:
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"}}}