Skip to content

Chat App

Denis Hilt edited this page Nov 5, 2021 · 19 revisions

Chat App

Let's discuss how to build Chat App with ngx-ui-scroll. The end result is available at https://stackblitz.com/edit/ngx-ui-scroll-chat-app-2.

What is Chat App UI? User checks in to a channel and receives some messages previously pushed to this channel (history). After initialization user can scroll up to retrieve old messages and can push new messages. Also, other users can push new messages to this channel, and the App should have a sort of subscription and update chat UI at runtime. This way we may highlight 3 basic scenarios.

Basic scenarios

Below is the diagram of 3 basic scenarios: connect, scroll and push. Connect means initialization of a given chat channel and receiving latest channel messages. Scroll is the way to move back and forth through the channel history. Push corresponds to sending and receiving new messages at runtime. All scenarios are considered from the User 1 point of view. That means that along with User 2 there could be unlimited number of other users pushing unlimited number of messages into a channel. Scenarios are placed in up-to-bottom sequence: 1 connect, 1 scroll and 2 pushes (backward and forward).

Chat App  Remote - Clients

Connect

Client App should connect to a channel and receive initial messages. This depends on the infrastructure, but the process is obviously asynchronous and the initialization of the Chat UI should not be started before the App got the result of this process. Two things are required in the result:

  • initial set of messages (N items);
  • number of messages in storage at the moment of connect (X, total).

Note, that the size of the initial set is less than (or rarely equal to) X. For example, if there are no messages in the remote channel storage or there are, say, only 3 messages, then N could be equal to X. But if there are X = 999 messages in the remote channel storage, then it seems a good decision to send only N = 10 last messages. In this case we'll have { items, total: 999 } as the result of connect, where items array contains last 10 messages.

So the App in the end of this phase will have N items started from X - N position: [X - N .. X].

Scroll

When the Chat UI is initialized with last N messages, we may start scroll to retrieve and render more messages persisted in the remote channel storage. This is the second basic scenario which runs from time to time in response to user scroll. This is handled by the Scroller engine automatically, but in accordance with the Datasource API we need to implement and provide get method which can retrieve count items started from index position from the remote storage.

The diagram shows only 1 scroll event that results in requesting M items started from (X - N - M) position. Note, X - N came from the initial (connect) scenario. This request will be triggered by the scroller via datasource.get(X - N - M, M) call. Basically, almost all scroll events are accomplished with internal datasource.get calls. We need just to implement the relationship between client App and the remote channel storage to be able to get the exact portion of the data based on index-count approach.

This way the App will have N + M items started from X - (N + M) position: [X - N - M .. X].

Push

Another user pushes a new message to our channel. The App should handle this event and inject this new message to the Chat UI. The Scroller API allows it via Adapter.append method. We are assuming that this new message is the last one in the remote channel storage, so X is not the last index anymore, the last index is X + 1 and the App should have N + M + 1 items started from X - (N + M) position: [X - N - M .. X + 1].

If we'd want to response, the scenario will be the same. The client App pushes 1 more item to the remote channel storage which should notify other users. Simultaneously the App appends this message to the chat UI, so it has N + M + 2 items started from X - (N + M) position: [X - N - M .. X + 2].

Message Service

3 scenarios described above are translated into 3 requirements that can be met as a part of a single service. So, let it be MessageService and let it implement following public entities:

  • initialize(): corresponds to connect scenario. A method designed for connecting to a given channel and getting some last messages from its history. It should receive and store a) an array of last N items in the channel, b) a number of items in the channel at the moment of initialization.

  • request(index, count): corresponds to scroll scenario. A method designed to receive a fixed portion of channel messages specified by the arguments. It should return count messages started from index position in assumption that the first index is 0 and the last index at the moment of initialization is total - 1. The return value should be an observable of item list: Observable<Item[]>.

  • onPush$: corresponds to push scenario. An observable of Subject<Item[]> type that delivers new messages pushed to the channel at runtime.

Remote Service

Trying to be as abstract as possible, let's suppose we have another service providing necessary API to deal with the remote channel. This service incapsulates all backend-specific operations. No matter what the backend we have (RESTful API, GraphQL, Firebase etc), we'll only put here what is directly related to MessageService and its requirements.

For example, from the backend perspective there may be no difference between the initial and the common data requests, but the Remote service abstraction hides the equality in this case and provides two different methods a) returning { items, total } observable for connect scenario, b) returning a list of items for scroll scenario.

The same happens with on-push notifications. It might be custom socket implementation or some out-of-box solution (like firebase .on), but here we'll use RemoteService.newData$ observable which is supposed to be notifications provider.

It might seem that the logic of RemoteService duplicates the logic of MessageService in the demo App, but MessageService is a skeleton with fixed API, while RemoteService should be completely re-written depending on the App needs. Re-written or even inlined into MessageService, so it can be treated as a part of MessageService.

Cache

On top of the requirements listed above we need to arrange a sort of local storage, which will be responsible for caching data from the remote channel and provide fast access to messages that were already received. Let's use javascript Map for this: Map<number, Item>, where the key (number) is position of the item in the channel item list. Also, we'll need lastIndex property, which is the position of the last message in the remote channel. By adding items to cache we need to take care of lastIndex consistency. It could be easily done with the following approach:

lastIndex = -1;
private cache = new Map<number, Item>();

private persist(index: number, item: Item) {
  this.cache.set(index, item);
  if (index > this.lastIndex) {
    this.lastIndex = index;
  }
}

This is also a part of MessageService. The cache has two purposes:

  • provide an access to initial set of items (and total) on the Chat UI initialization (connect)
  • provide quick access to items that were already requested from the remote channel storage on scroll

MessageService.initialize

It seems reasonable that onPush$ observable can be handled on connect as well as the initial channel data request. It will emit lists of new items every time new messages arrive in the channel, but its initialization is a one-time event. So, we can try to accomplish two goals at once.

initialize(channelMetaData) {
  // connect and request the initial channel data
  this.remoteService.connect(channelMetaData)
    .subscribe(({ items, total }) => {
      // store last channel messages in the service cache
      items.forEach((item, i) =>
      	// persisting index iterates from X - N to X
      	// where X = total and N = items.length
        this.persist(total - items.length + i, item)
      );

      // run onPush$ emitting process
      this.remoteService.newData$
        .subscribe(items => {
          // store new channel messages in the cache
          items.forEach((item, i) =>
            // persisting index just increases from X1 to X1 + N1
            // where X1 is current lastIndex and N1 = items.length
            this.persist(this.lastIndex + 1, item)
          );
          // on-push emitter
          this.onPush$.next(items);
      });
  });
}

So when the initialization is done, the App has

  • some last messages in the MessageService cache
  • the position of the last message stored as lastIndex
  • new messages emitter, which also persists new data and maintain the last position at runtime

MessageService.request

This method implements regular data requests based on index-count approach in accordance with scroll scenario. It uses internal cache storage to avoid duplicate calls. The cache optimization could be more complicated than the one presented here, it may include merges for example. But here we use cached data only if it covers the entire request.

takeFromCache(index: number, count: number): null | Item[] {
  const cached: Item[] = [];
  for (let i = index; i < index + count; i++) {
    const item = this.cache.get(i);
    if (item) {
      cached.push(item);
    }
  }
  return cached.length === count ? cached : null;
}

Very simple, but this lets us to reduce efforts... The next point is the request method which uses takeFromCache method and requests count items started from index position. If the cache can't help, RemoteService.retrieve fake method is called.

request(index: number, count: number): Observable<Item[]> {
  // try cache
  const cached = this.takeFromCache(index, count);
  if (cached) {
    console.log(`taken from cache (${count})`);
    return of(cached);
  }
  // request
  return this.remoteService.retrieve(index, count)
    .pipe( // response caching
      tap(items =>
        items.forEach((item, i) =>
          // RemoteService should guarantee `index` consistency
          this.persist(index + i, item)
        )
      )
    );
}

We assume that RemoteService returns an observable which can be a) transferred as is to the MessageService.request method caller, b) intercepted via RxJs operator tap to provide caching.

Continue

3 basic scenarios got the implementation inside the MessageService. The connection of the MessageService with the RemoteService and the Scroller can be taken from the demo source code.