RxJs: Incorporating timed delays for chatbot responses

I am currently developing a chat application and I am looking to display bot messages sequentially with a delay between each message. This will create the illusion that the bot is typing out the messages instead of sending them all at once. I initially attempted this using RxJS but was unable to achieve the desired effect.

Check out the Stackblitz Link here.

query(): Observable<IChatResponse> {
    const response = {
      messages: [
        {
          text: 'Please provide a valid domain name',
        },
        {
          text: 'What domain are you interested in?',
        },
        {
          text: 'Some other messages...',
        },
      ],
    };

    return of(response).pipe(
      switchMap((response: any) => this.convertToStream(response))
    );
  }

  convertToStream(data: any): Observable<IChatResponse> {
    let count = 0;
    const messageDelayFn = (message, index): Observable<any> => {
      const loaderStart$ = of(null).pipe(
        tap((_) => console.log('index ', index)),
        delay(500 * index),
        tap((_) => {
          this.loading$.next(true);
        })
      );
      const loaderStop$ = of(null).pipe(
        delay(1000 * index),
        tap((_) => {
          this.loading$.next(false);
        })
      );

      const message$ = of(message);
      return concat(loaderStart$, loaderStop$, message$).pipe(share());
    };

    const transformedObservable = of(data).pipe(
      map((chat) => {
        return {
          ...chat,
          messages: chat.messages.reduce((acc: Observable<any>[], message) => {
            return [...acc, messageDelayFn(message, ++count)];
          }, []),
        };
      })
    );

    return transformedObservable;
  }

The expected behavior I am aiming for is as follows:

  • Starts with loader for 500ms
  • Loader stops
  • Emits first message
  • Loader starts again for 500ms (before emitting second message)
  • Loader stops
  • Emits second message
  • ...and so on

Answer №1

Check out this function that meets your requirements. Feel free to test it out and modify it to fit your specific needs.

function fetchData(): Observable<string> {
  const data = {
    messages: [
      {
        text: 'Please enter a valid email address',
      },
      {
        text: 'What is your email?',
      },
      {
        text: 'Some other messages...',
      },
    ],
  };

  return concat(...data.messages.map(({text}) => 
    timer(500).pipe(map(_ => text))
  ));
}

fetchData().subscribe(console.log);

Answer №2

service

export default class AppService {
  query(): Observable<IMessage> {
    const response = {
      messages: [
        {
          text: 'Please provide a valid domain name',
        },
        {
          text: 'Which domain would you like to use?',
        },
        {
          text: 'Other messages...',
        },
      ],
    };
    return from(response.messages);
  }
}

component

export class AppComponent {
  text$ = this.appService.query().pipe(
    concatMap(({ text }) => {
      const share$ = of(text).pipe(delay(1000), share());
      return share$.pipe(delay(1000), startWith(share$), skipLast(1));
    }),
    scan((arr, v) => [...arr, v], [])
  );
  constructor(private appService: AppService) {}
}

html

<div *ngFor="let item of text$ | async">
  <div class="text">
    <span *ngIf="item | async as item; else loading">
      {{ item }}
    </span>
    <ng-template #loading>
      <img src="https://c.tenor.com/VS20soWAM9AAAAAi/loading.gif" width="30"/>
    </ng-template>
  </div>
</div>

https://stackblitz.com/edit/angular-ivy-ot6dcr

Answer №3

If you reconsider the way you approach the "state" in your code, you can greatly simplify it. Instead of handling "loading" and "messages" separately, you can view them as part of a unified interface:

export interface ChatBotState {
  messages: string[];
  isTyping: boolean;
}

We can create an observable that emits this state in the desired manner.

Start by querying to get all messages and emitting them individually, creating a stream of messages:

  private chatBotMessage$ = this.appService.query().pipe(
    switchMap(response => response.messages)
  );

Then utilize concatMap / of to emit ChatBotStates with the specified timings:

  public state$: Observable<ChatBotState> = this.chatBotMessage$.pipe(
    concatMap(message => concat(
      of({ isTyping: true,  message: undefined    }).pipe(delay(0)),
      of({ isTyping: false, message: message.text }).pipe(delay(500)),
    )),
    // ... more to come

This approach involves emitting a state with isTyping = true immediately, followed by a state with the message text and isTyping = false after 500ms.

Use scan to emit a resulting state object that includes all messages:

const INITIAL_STATE: ChatBotState = {
  messages: [],
  isTyping: false,
};
  public state$: Observable<ChatBotState> = this.chatBotMessage$.pipe(
    concatMap(message => concat(
      of({ isTyping: true,  message: undefined    }).pipe(delay(0)),
      of({ isTyping: false, message: message.text }).pipe(delay(1500)),
    )),
    scan((previous, current) => ({
      isTyping: current.isTyping,
      messages: current.message 
                  ? previous.messages.concat(current.message) 
                  : previous.messages
    }), INITIAL_STATE)
  );

Here is a functioning demo on StackBlitz for reference.


By ensuring the state$ observable emits the necessary data for the view, the component becomes much simpler! No need to handle a subscription, so no requirement for ngOnInit or ngOnDestroy. Just utilize a single async pipe in the template to access the view model.

export class AppComponent {

  vm$ = this.chatBotService.state$;

  constructor(private chatBotService: ChatBotService) { }

}
<div *ngIf="vm$ | async as vm">
  
  <p *ngFor="let message of vm.messages">
    {{ message }}
  </p>

  <div *ngIf="vm.isTyping">
    Loading...
  </div>

</div>

Answer №4

My approach to solving this problem involves using the subject in combination with concat. By using concat, the next observable is executed only after the previous one completes. I have also added a delay to meet the requirements specified.

service

import { Injectable } from '@angular/core';
import {
  concat,
  map,
  Observable,
  of,
  share,
  switchMap,
  tap,
  delay,
  Subject,
  BehaviorSubject,
  concatMap,
} from 'rxjs';
import { IChatResponse } from './app.component';

@Injectable({ providedIn: 'root' })
export default class AppService {
  loading$ = new Subject<boolean>();
  get loadingStream(): Observable<boolean> {
    return this.loading$.asObservable();
  }

  query(): Observable<any> {
    const subject = new BehaviorSubject(['loading']);
    const response = {
      messages: [
        {
          text: 'Please give a valid domain name',
        },
        {
          text: 'What domain do you want?',
        },
        {
          text: 'Some other messages...',
        },
      ],
    };
    const observables$ = response.messages.map((x) => {
      return of(x.text).pipe(delay(3000));
    });

    return concat(...observables$).pipe(
      tap((x) => {
        const arr = subject.getValue();
        arr[arr.length - 1] = x;
        subject.next(
          arr.length === response.messages.length ? arr : [...arr, 'loading']
        );
      }),
      concatMap(() => subject.asObservable())
    );
  }
}

ts

import { Component, OnDestroy, OnInit, VERSION } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import AppService from './app.service';

export interface IChatResponse {
  messages: Observable<IMessage>[];
}
interface IMessage {
  text: string;
}

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent implements OnInit, OnDestroy {
  name = 'Angular ' + VERSION.major;
  chatMessages$: any;
  subscription = new Subscription();
  loader$: Observable<boolean>;
  constructor(private appService: AppService) {
    this.loader$ = this.appService.loadingStream;
  }

  ngOnInit(): void {
    this.chatMessages$ = <any>this.appService.query();
  }

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}

html

<hello name="{{ name }}"></hello>
<p *ngFor="let message of chatMessages$ | async">
  <ng-container [ngSwitch]="message">
    <ng-container *ngSwitchCase="'loading'">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        xmlns:xlink="http://www.w3.org/1999/xlink"
        style="margin:auto;background:#fff;display:block;"
        width="200px"
        height="200px"
        viewBox="0 0 100 100"
        preserveAspectRatio="xMidYMid"
      >
        <g transform="translate(20 50)">
          <circle cx="0" cy="0" r="6" fill="#e15b64">
            <animateTransform
              attributeName="transform"
              type="scale"
              begin="-0.375s"
              calcMode="spline"
              keySplines="0.3 0 0.7 1;0.3 0 0.7 1"
              values="0;1;0"
              keyTimes="0;0.5;1"
              dur="1s"
              repeatCount="indefinite"
            ></animateTransform>
          </circle>
        </g>
        <g transform="translate(40 50)">
          <circle cx="0" cy="0" r="6" fill="#f8b26a">
            <animateTransform
              attributeName="transform"
              type="scale"
              begin="-0.25s"
              calcMode="spline"
              keySplines="0.3 0 0.7 1;0.3 0 0.7 1"
              values="0;1;0"
              keyTimes="0;0.5;1"
              dur="1s"
              repeatCount="indefinite"
            ></animateTransform>
          </circle>
        </g>
        <g transform="translate(60 50)">
          <circle cx="0" cy="0" r="6" fill="#abbd81">
            <animateTransform
              attributeName="transform"
              type="scale"
              begin="-0.125s"
              calcMode="spline"
              keySplines="0.3 0 0.7 1;0.3 0 0.7 1"
              values="0;1;0"
              keyTimes="0;0.5;1"
              dur="1s"
              repeatCount="indefinite"
            ></animateTransform>
          </circle>
        </g>
        <g transform="translate(80 50)">
          <circle cx="0" cy="0" r="6" fill="#81a3bd">
            <animateTransform
              attributeName="transform"
              type="scale"
              begin="0s"
              calcMode="spline"
              keySplines="0.3 0 0.7 1;0.3 0 0.7 1"
              values="0;1;0"
              keyTimes="0;0.5;1"
              dur="1s"
              repeatCount="indefinite"
            ></animateTransform>
          </circle>
        </g>
      </svg>
    </ng-container>
    <ng-container *ngSwitchDefault>{{ message }}</ng-container>
  </ng-container>
</p>

Check out my modified stackblitz

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Encapsulate the module function and modify its output

I am currently utilizing the node-i18n-iso-countries package and I need to customize the getNames function in order to accommodate a new country name that I wish to include. At the moment, I am achieving this by using an if-else statement like so: let cou ...

Running complex Firestore query within Cloud Functions

Currently, I am developing triggers that interact with a Firestore movie and user database. The main goal of one trigger is to present a new user with a list of top-rated movies in genres they have selected as their favorites. To achieve this, I store the ...

To dismiss a popup on a map, simply click on any area outside the map

Whenever I interact with a map similar to Google Maps by clicking on various points, a dynamically generated popup appears. However, I am facing an issue where I want to close this popup when clicking outside the map area. Currently, the code I have writte ...

Exploring the JSON Array in Angular5 with the power of ngFor

Currently, I am working on a project using Angular5 and encountering an issue with the *ngFor directive. The model class I have defined looks like this: export class FetchApi { value: Array<String>; api_status: string; api_version: string; d ...

What kind of impact on performance can be expected when using index.ts in a Typescript - Ionic App?

When setting up the structure of a standard Ionic app, it typically looks like this: app pages ----page1 ---------page1.ts ----page2 ---------page2.ts If I were to include an index.ts file in the pages folder as follows: pages/index.ts export { Page1 } ...

Stop ngOnChanges from being triggered after dispatching event (Angular 2+)

In Angular 2+, a custom two-way binding technique can be achieved by utilizing @Input and @Output parameters. For instance, if there is a need for a child component to communicate with an external plugin, the following approach can be taken: export class ...

Angular promise not triggering loop creation

I am encountering an issue with a particular function handleFileInput(file: any) { let promise = new Promise((resolve, reject) => { this.uploadFileDetails.push({ filename:this.FileName,filetype:this.FileType}); ... resolve(dat ...

Why am I having trouble iterating through my array object?

Trying to showcase the contents of object within an array. However, unable to showcase any results. Why is this happening? This is what I've attempted so far: Demo available here: https://stackblitz.com/edit/ionic-dsdjvp?file=pages%2Fhome%2Fhome.ts ...

The attribute "property" is not found in the specified type of "Request<ParamsDictionary>"

Struggling to enhance the Request interface in the express package with custom properties, I keep encountering this TypeScript error: TS2339: Property '' does not exist on type 'Request<ParamsDictionary>'. Any ideas on how to re ...

Was not able to capture the reaction from the Angular file upload

I've been attempting to upload a single file using the POST Method and REST Calling to the server. Despite successfully uploading the module with the code below, I encounter an error afterwards. Is there anyone who can lend assistance in troubleshooti ...

Implementing Bootstrap 5 within Angular 14 and incorporating SCSS styling

Currently, I am in the process of integrating Bootstrap 5 into my Angular 14 project, which utilizes SCSS instead of CSS. After successfully installing Bootstrap via npm (npm install bootstrap --save), the package is properly listed in my package.json file ...

The production build was unsuccessful due to issues with pdfmake during the ng build

Currently, I am working on an Angular 6.1.4 project using pdfmake 0.1.63 (also tested with version 0.1.66). While running ng build --prod command, I have encountered the following issue: ERROR in ./node_modules/pdfmake/build/pdfmake.js ...

Angular 2 event emitter falling behind schedule

I am currently utilizing Angular 2 beta 6. The custom event I created is not being captured import {Component, OnInit, EventEmitter} from 'angular2/core'; import {NgForm} from 'angular2/common'; import {Output} from "angular2/core" ...

Angular2 (AngularCLI) cannot locate the Elasticsearch module

Currently, I am attempting to incorporate the Elasticsearch NPM Module into my Angular2 application. In order to use it in my service, I have imported it as follows: import { Client, SearchResponse } from 'elasticsearch'; Even though there are ...

Looking for help with aligning an icon to the right side of my text

I'm struggling with aligning the icon to the right of the quantity. When I use float:right, it places the icon at the far right of the cell instead of next to the text. Is there a way to get it to be on the right side of the text but directly adjacent ...

Tips for managing an array of observable items

In my current project, I am working with an Angular application that receives a collection from Firebase (Observable<any[]>). For each element in this collection, I need to create a new object by combining the original value with information from ano ...

Angular 2 Integration for Slick Carousel

Greetings! I have recently ventured into Angular 2 and am currently attempting to get a carousel plugin called slick up and running. After some trial and error, I have successfully implemented it as a Component in the following manner: import { Component ...

Helping React and MUI components become mobile responsive - Seeking guidance to make it happen

My React component uses Material-UI (MUI) and I'm working on making it mobile responsive. Here's how it looks currently: https://i.sstatic.net/kxsSD.png But this is the look I want to achieve: https://i.sstatic.net/kJC2m.png Below is the code ...

What is the best way to set one property to be the same as another in Angular?

In my project, I have defined a class called MyClass which I later utilize in an Angular component. export class MyClass { prop: string; constructor(val){ this.prop = val; } public getLength(str: string): number { return str.length; } ...