Fork me on GitHub

WhatsApp Clone with Meteor and Ionic 2 CLI

Ionic 3 Version (Last Update: 2017-06-15)

Filter & Pagination

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

Lazy-Loading

In this step, we will implement a lazy-loading mechanism in the MessagesPage. Lazy loading means that only the necessary data will be loaded once we're promoted to the corresponding view, and it will keep loading, but gradually. In the MessagesPage case, we will only be provided with several messages once we enter the view, enough messages to fill all of it, and as we scroll up, we will provided with more messages. This way we can have a smooth experience, without the cost of fetching the entire messages collection. We will start by limiting our messages subscription into 30 documents:

10.1 Added counter for messages publication api/server/publications.ts
15
16
17
18
19
20
21
22
23
 
25
26
27
28
29
30
31
32
  });
});
 
Meteor.publish('messages', function(
  chatId: string,
  messagesBatchCounter: number): Mongo.Cursor<Message> {
  if (!this.userId || !chatId) {
    return;
  }
...some lines skipped...
  return Messages.collection.find({
    chatId
  }, {
    sort: { createdAt: -1 },
    limit: 30 * messagesBatchCounter
  });
});
 

As we said, we will be fetching more and more messages gradually, so we will need to have a counter in the component which will tell us the number of the batch we would like to fetch in our next scroll:

10.2 Add counter to client side src/pages/messages/messages.ts
23
24
25
26
27
28
29
 
66
67
68
69
70
71
72
73
  senderId: string;
  loadingMessages: boolean;
  messagesComputation: Subscription;
  messagesBatchCounter: number = 0;
 
  constructor(
    navParams: NavParams,
...some lines skipped...
    this.scrollOffset = this.scroller.scrollHeight;
 
    MeteorObservable.subscribe('messages',
      this.selectedChat._id,
      ++this.messagesBatchCounter
    ).subscribe(() => {
      // Keep tracking changes in the dataset and re-render the view
      if (!this.messagesComputation) {

By now, whether you noticed or not, we have some sort of a limitation which we have to solve. Let's say we've fetched all the messages available for the current chat, and we keep scrolling up, the component will keep attempting to fetch more messages, but it doesn't know that it reached the limit. Because of that, we will need to know the total number of messages so we will know when to stop the lazy-loading mechanism. To solve this issue, we will begin with implementing a method which will retrieve the number of total messages for a provided chat:

10.3 Implement countMessages method on server side api/server/methods.ts
90
91
92
93
94
95
96
97
        type: type
      })
    };
  },
  countMessages(): number {
    return Messages.collection.find().count();
  }
});

Now, whenever we fetch a new messages-batch we will check if we reached the total messages limit, and if so, we will stop listening to the scroll event:

10.4 Implement actual load more logic src/pages/messages/messages.ts
6
7
8
9
10
11
12
 
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
 
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import * as moment from 'moment';
import * as _ from 'lodash';
import { MessagesOptionsComponent } from './messages-options';
import { Subscription, Observable, Subscriber } from 'rxjs';
 
@Component({
  selector: 'messages-page',
...some lines skipped...
  ngOnInit() {
    this.autoScroller = this.autoScroll();
    this.subscribeMessages();
 
    // Get total messages count in database so we can have an indication of when to
    // stop the auto-subscriber
    MeteorObservable.call('countMessages').subscribe((messagesCount: number) => {
      Observable
      // Chain every scroll event
        .fromEvent(this.scroller, 'scroll')
        // Remove the scroll listener once all messages have been fetched
        .takeUntil(this.autoRemoveScrollListener(messagesCount))
        // Filter event handling unless we're at the top of the page
        .filter(() => !this.scroller.scrollTop)
        // Prohibit parallel subscriptions
        .filter(() => !this.loadingMessages)
        // Invoke the messages subscription once all the requirements have been met
        .forEach(() => this.subscribeMessages());
    });
  }
 
  ngOnDestroy() {
...some lines skipped...
    });
  }
 
  // Removes the scroll listener once all messages from the past were fetched
  autoRemoveScrollListener<T>(messagesCount: number): Observable<T> {
    return Observable.create((observer: Subscriber<T>) => {
      Messages.find().subscribe({
        next: (messages) => {
          // Once all messages have been fetched
          if (messagesCount !== messages.length) {
            return;
          }
 
          // Signal to stop listening to the scroll event
          observer.next();
 
          // Finish the observation to prevent unnecessary calculations
          observer.complete();
        },
        error: (e) => {
          observer.error(e);
        }
      });
    });
  }
 
  showOptions(): void {
    const popover = this.popoverCtrl.create(MessagesOptionsComponent, {
      chat: this.selectedChat

Filter

Now we're gonna implement a search-bar, in the NewChatComponent.

Let's start by implementing the logic using RxJS. We will use a BehaviorSubject which will store the search pattern entered in the search bar, and we will be able to detect changes in its value using the Observable API; So whenever the search pattern is being changed, we will update the users list by re-subscribing to the users subscription:

10.5 Implement the search bar logic with RxJS src/pages/chats/new-chat.ts
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 
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
 
58
59
60
61
62
63
64
65
66
67
68
69
import { AlertController, ViewController } from 'ionic-angular';
import { MeteorObservable } from 'meteor-rxjs';
import * as _ from 'lodash';
import { Observable, Subscription, BehaviorSubject } from 'rxjs';
 
@Component({
  selector: 'new-chat',
  templateUrl: 'new-chat.html'
})
export class NewChatComponent implements OnInit {
  searchPattern: BehaviorSubject<any>;
  senderId: string;
  users: Observable<User[]>;
  usersSubscription: Subscription;
...some lines skipped...
    private viewCtrl: ViewController
  ) {
    this.senderId = Meteor.userId();
    this.searchPattern = new BehaviorSubject(undefined);
  }
 
  ngOnInit() {
    this.observeSearchBar();
  }
 
  updateSubscription(newValue) {
    this.searchPattern.next(newValue);
  }
 
  observeSearchBar(): void {
    this.searchPattern.asObservable()
    // Prevents the search bar from being spammed
      .debounce(() => Observable.timer(1000))
      .forEach(() => {
        if (this.usersSubscription) {
          this.usersSubscription.unsubscribe();
        }
 
        this.usersSubscription = this.subscribeUsers();
      });
  }
 
  addChat(user): void {
...some lines skipped...
    });
  }
 
  subscribeUsers(): Subscription {
    // Fetch all users matching search pattern
    const subscription = MeteorObservable.subscribe('users', this.searchPattern.getValue());
    const autorun = MeteorObservable.autorun();
 
    return Observable.merge(subscription, autorun).subscribe(() => {
      this.users = this.findUsers();
    });
  }

Note how we used the debounce method to prevent subscription spamming. Let's add the template for the search-bar in the NewChat view, and bind it to the corresponding data-models and methods in the component:

10.6 Update usage src/pages/chats/new-chat.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 
18
19
20
21
22
23
24
<ion-header>
  <ion-toolbar *ngIf="searching" color="whatsapp">
    <ion-searchbar
      autofocus
      class="seach-bar"
      color="whatsapp"
      [showCancelButton]="true"
      (ionInput)="updateSubscription($event.target.value); searching = true;"
      (ionClear)="updateSubscription(undefined); searching = false;">
      </ion-searchbar>
  </ion-toolbar>
 
  <ion-toolbar *ngIf="!searching" color="whatsapp">
    <ion-title>New Chat</ion-title>
 
    <ion-buttons left>
...some lines skipped...
    </ion-buttons>
 
    <ion-buttons end>
      <button ion-button class="search-button" (click)="searching = true"><ion-icon name="search"></ion-icon></button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

Now we will modify the users subscription to accept the search-pattern, which will be used as a filter for the result-set;

10.7 Add search pattern to the publication api/server/publications.ts
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
import { Messages } from './collections/messages';
import { Chats } from './collections/chats';
 
Meteor.publishComposite('users', function(
  pattern: string
): PublishCompositeConfig<User> {
  if (!this.userId) {
    return;
  }
 
  let selector = {};
 
  if (pattern) {
    selector = {
      'profile.name': { $regex: pattern, $options: 'i' }
    };
  }
 
  return {
    find: () => {
      return Users.collection.find(selector, {
        fields: { profile: 1 },
        limit: 15
      });
    }
  };
});
 
Meteor.publish('messages', function(