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);
  }
}
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 {}
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$;
  }
}
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>
last-active.component.html

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

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());
  }
  ...
}
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());
  }
  ...
}
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));
}
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;
  }
}
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();
  }
}
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>
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;
  }
  ...
}
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);
    });
  }
}
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 {}
app.module.ts

And that's all there is to it!

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

GitHub - mzwierzchlewski/AngularInactivityDemo: Demo application showcasing user (in)activity detection.
Demo application showcasing user (in)activity detection. - GitHub - mzwierzchlewski/AngularInactivityDemo: Demo application showcasing user (in)activity detection.

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.

Show Comments