-
-
Notifications
You must be signed in to change notification settings - Fork 18
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.
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).
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]
.
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]
.
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]
.
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 fromindex
position in assumption that the first index is 0 and the last index at the moment of initialization istotal - 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.
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
.
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
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
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.
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.