7

Detecting user (in)activity in Angular

 2 years ago
source link: https://maciejz.dev/detecting-user-inactivity-angular/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Article Angular

Detecting user (in)activity in Angular

Detecting user (in)activity in Angular

Sometimes it's important to know whether a user has been inactive for a while. That's especially true when displaying sensitive information on the screen, which should be hidden if a longer period of inactivity is detected.

This post will guide you through implementing user inactivity detection in Angular. No time to waste!

Let's start with a simple service:

@Injectable({
  providedIn: 'root',
})
export class LastActiveService {
  private localStorageKey: string = '__lastActive';
  private events: string[] = ['keydown', 'click', 'wheel', 'mousemove'];

  private lastActive: BehaviorSubject<Date>;
  public lastActive$: Observable<Date>;

  constructor() {
    const lastActiveDate = this.getLastActiveFromLocalStorage() ?? new Date();
    this.lastActive = new BehaviorSubject<Date>(lastActiveDate);
    this.lastActive$ = this.lastActive.asObservable();
  }

  public setUp() {
    this.events.forEach(event =>
      fromEvent(document, event).subscribe(_ => this.recordLastActiveDate())
    );
  }
  
  private recordLastActiveDate() {
    var currentDate = new Date(); 
    localStorage.setItem(this.localStorageKey, currentDate.toString());
    this.lastActive.next(currentDate);
  }

  private getLastActiveFromLocalStorage(): Date | null {
    const valueFromStorage = localStorage.getItem(this.localStorageKey);
    if (!valueFromStorage) {
      return null;
    }

    return new Date(valueFromStorage);
  }
}
12345678910111213141516171819202122232425262728293031323334353637
TypeScript

last-active.service.ts

There's nothing groundbreaking here. Using the fromEvent function from RxJS we subscribe to events indicating that the user is still there (keydown, click, wheel, and mousemove). When any of the events is fired, the current date is saved to local storage and published to the lastActive BehaviorSubject that is available to other services via the lastActive public Observable.

Now we only need to figure out how to call the setUp method when the application starts. Thankfully, Angular provides us with the option to do just that using the APP_INITIALIZER DI token.

To make use of it, add the following to your AppModule's NgModule providers section:

@NgModule({
  ...
  providers: [
    ...
    {
      provide: APP_INITIALIZER,
      multi: true,
      deps: [LastActiveService],
      useFactory: (lastActiveService: LastActiveService) => () =>
        lastActiveService.setUp(),
    },
    ...
  ],
  ...
})
export class AppModule {}
12345678910111213141516
TypeScript

app.module.ts

And that's it - to check if everything is working as intended, let's create a component that will display how long ago the user was active.

@Component({
  selector: 'app-last-active',
  templateUrl: './last-active.component.html',
  styleUrls: ['./last-active.component.css'],
})
export class LastActiveComponent {
  public lastActiveDate$: Observable<Date>;

  constructor(lastActiveService: LastActiveService) {
    this.lastActiveDate$ = lastActiveService.lastActive$;
  }
}
123456789101112
TypeScript

last-active.component.ts

<div *ngIf="lastActiveDate$ | async as lastActiveDate; else loading">
  <p>Last active {{ lastActiveDate | amTimeAgo }}.</p>
</div>

<ng-template #loading>
  <p>Initialising...</p>
</ng-template>
1234567

last-active.component.html

Then, add the component onto a page and test it out.

image-7.png

It works ?

Note: the amTimeAgo pipe comes from the ngx-moment package.

Looks good! There's one caveat, though. Since we subscribed to the mousemove event on the whole document, the LastActiveService class updates the local storage entry and publishes new values to the lastActive$ Observable a lot. That might lead to performance issues down the line due to many unnecessary re-renders of dependent components.

Now, there are at least two ways to limit the number of updates broadcasted from the service. The first one involves a simple modification to the recordLastActiveDate method that forces an early return when too little time has passed since the last update.

@Injectable({
  providedIn: 'root',
})
export class LastActiveService {
  private recordTimeoutMs: number = 500;
  ...  
  private recordLastActiveDate() {
    var currentDate = moment(new Date());
    if (moment.duration(currentDate.diff(this.getLastActiveDate())).asMilliseconds() < this.recordTimeoutMs) {
      return;
    }

    localStorage.setItem(this.localStorageKey, currentDate.toString());
    this.lastActive.next(currentDate.toDate());
  }
  ...
}
1234567891011121314151617
TypeScript

last-active.service.ts

This works but is certainly not the most elegant solution. The second one achieves the same result but is powered by RxJS magic.

export class LastActiveService {
  private recordTimeoutMs: number = 500;
  ...
  public setUp() {
    from(this.events).pipe(mergeMap(event => fromEvent(document, event)), throttleTime(this.recordTimeoutMs)).subscribe(_ => this.recordLastActiveDate());
  }
  ...
}
12345678
TypeScript

last-active.service.ts

To me, this is much cleaner, but if you prefer the first version, go for it!

There is one more thing that could be improved here. If you open your application in two separate tabs, one of them will have outdated information about the user's last activity date.

To overcome this, we can use the storage event to "communicate" between different tabs. Update the setUp method with the following code:

public setUp() {
  ...
  fromEvent<StorageEvent>(window, 'storage')
    .pipe(
      filter(event => event.storageArea === localStorage
        && event.key === this.localStorageKey
        && !!event.newValue),
      map(event => new Date(event.newValue))
    )
    .subscribe(newDate => this.lastActive.next(newDate));
}
1234567891011
TypeScript

last-active.service.ts

That should do it ?.

So far we only used the (in)activity detection to display when the user was last active. Let's do one more example. Image a service:

export class LoginService {
  private localStorageKey: string = '__loggedIn';

  private loggedIn: BehaviorSubject<boolean>;
  public loggedIn$: Observable<boolean>;

  constructor(private lastActiveService: LastActiveService) {
    this.loggedIn = new BehaviorSubject(this.getLoggedInFromLocalStorage() ?? false);
    this.loggedIn$ = this.loggedIn.asObservable();
  }

  public logIn() {
    localStorage.setItem(this.localStorageKey, 'true');
    this.loggedIn.next(true);
  }

  private logOut() {
    localStorage.removeItem(this.localStorageKey);
    this.loggedIn.next(false);
  }

  private getLoggedInFromLocalStorage(): boolean | null {
    const valueFromStorage = localStorage.getItem(this.localStorageKey);
    if (!valueFromStorage) {
      return null;
    }

    return !!valueFromStorage;
  }
}
123456789101112131415161718192021222324252627282930
TypeScript

login.service.ts

And a component:

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css'],
})
export class LoginComponent {
  public loggedIn$: Observable<boolean>;

  constructor(private loginService: LoginService) {
    this.loggedIn$ = loginService.loggedIn$;
  }

  public logIn() {
    this.loginService.logIn();
  }
}
12345678910111213141516
TypeScript

login.component.ts

<div *ngIf="{ loggedIn: loggedIn$ | async } as loginStatus">
  <ng-container *ngIf="loginStatus.loggedIn">
    <p>Logged in! ?</p>
  </ng-container>
  <ng-container *ngIf="!loginStatus.loggedIn">
    <p>Not logged in! ?</p>
  </ng-container>
  <button (click)="logIn()" [disabled]="loginStatus.loggedIn">Login</button>
</div>
123456789

login.component.html

These allow the user to "log in". What about logging out? Let's log out the user automatically if they are inactive for 10 seconds. First, let's add a method to LastActiveService that will return the user's last active date.

export class LastActiveService {
  ...
  public getLastActiveDate(): Date {
    return this.lastActive.value;
  }
  ...
}
1234567
TypeScript

last-active.service.ts

Then, we can use that in the LoginService:

export class LoginService {
  private inactivityLogoutTimeoutS: number = 10;
  private timerTickMs: number = 500;

  public setUp() {
    timer(0, this.timerTickMs)
      .pipe(filter(_ => this.loggedIn.value))
      .subscribe(_ => {
        const currentDate = moment(new Date());
        const lastActiveDate = this.lastActiveService.getLastActiveDate();
        if (moment.duration(currentDate.diff(lastActiveDate)).asSeconds() > this.inactivityLogoutTimeoutS) {
          this.logOut();
        }
      });

	// since we are here anyway it won't hurt to synchronise the login state between different tabs
    fromEvent<StorageEvent>(window, 'storage')
    .pipe(
      filter(event => event.storageArea === localStorage
        && event.key === this.localStorageKey),
      map(event => !!event.newValue)
    )
    .subscribe(loggedIn => {
      this.loggedIn.next(loggedIn);
    });
  }
}
123456789101112131415161718192021222324252627
TypeScript

login.service.ts

The setUp method must be hooked up similarly to the one in the LastActiveService:

@NgModule({
  ...
  providers: [
    ...
    {
      provide: APP_INITIALIZER,
      multi: true,
      deps: [LoginService],
      useFactory: (loginService: LoginService) => () =>
        loginService.setUp(),
    },
    ...
  ],
  ...
})
export class AppModule {}
12345678910111213141516
TypeScript

app.module.ts

And that's all there is to it!

Load demo below.

You can look at all of the code in this repository:

AngularInactivityDemo

Cover photo by Quin Stevenson on Unsplash


Disclaimer: Some people might not like me using moment.js instead of a more modern library. The main reason for that is the ngx-moment module which provides the amTimeAgo pipe. I wanted to keep things simple and avoid writing a pipe in this post. I checked a few other packages for libraries such as Luxon and date-fns, but they simply did not work as well as ngx-moment.

</section


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK