inactivity-countdown-timer
Version:
A plain JS (Typescript) module that will countdown and timeout when users are inactive/idle.
330 lines (290 loc) • 11.7 kB
text/typescript
export interface IRegisterCallBacks {
timeoutCallback?(): void;
countDownCallback?(secondsLeft: number): void;
countDownCancelledCallback?(): void;
}
export interface IInactivityConfig extends IRegisterCallBacks {
startCountDownTimerAt?: number;
idleTimeoutTime?: number;
localStorageKey?: string;
resetEvents?: string[];
windowResetEvents?: string[];
throttleDuration?: number;
}
export interface ILogger {
log(message?: any, ...optionalParams: any[]): void;
}
export interface IInactivityDependencies {
logger?: ILogger;
localStorage?: Storage | null;
window?: Window;
document?: Document;
}
const defaultInactivityConfig: IInactivityConfig = {
idleTimeoutTime: 10000,
localStorageKey: 'inactivity_logout_local_storage',
resetEvents: ['click','mousemove','keypress'],
windowResetEvents: ['load'],
throttleDuration: 0
};
export enum InactivityCountdownTimerStatus {
started = 'started',
stopped = 'stopped'
}
export class InactivityCountdownTimer implements EventListenerObject {
// InactivityConfig
private idleTimeoutTime: number;
private startCountDownTimerAt: number;
private localStorageKey: string;
private resetEvents: string[] = [];
private windowResetEvents: string[] = [];
// IRegisterCallbacks
private timeoutCallback: () => void;
private countDownCallback: (secondsLeft: number) => void;
private countDownCancelledCallback: () => void;
// Internal vars
readonly localStorage: Storage | null;
private internalTimeoutTime: number;
private lastResetTimeStamp: number;
private countingDown: boolean = false;
private idleIntervalId: number;
private currentTimerPrecision: number;
private throttleDuration: number;
private throttleTimeoutId: number;
// status public
status: InactivityCountdownTimerStatus = InactivityCountdownTimerStatus.stopped;
get started(): boolean {
return this.status === InactivityCountdownTimerStatus.started
}
get stopped(): boolean {
return this.status === InactivityCountdownTimerStatus.stopped
}
// dom
private logger: ILogger | null;
private window: Window;
private document: Document;
constructor(
private params?: IInactivityConfig,
private deps?: IInactivityDependencies,
) {
this.logger = deps && deps.logger || console;
this.window = deps && deps.window || window;
this.document = deps && deps.document || document;
this.localStorage = this.detectAndAssignLocalStorage(deps && deps.localStorage);
if (params) { this.setup(params) }
}
/**
* @param params
* - **idleTimeoutTime**: 10000 - ms / 10 seconds
* - **localStorageKey**: 'inactivity_logout_local_storage'
*/
setup(params?: IInactivityConfig): {start: () => void} {
this.cleanup();
Object.assign(this, defaultInactivityConfig, params);
this.ensureReasonableTimings(params);
this.attacheEventListeners();
const start = () => this.start();
return {start};
}
// see EVENT_LISTENERS_THIS_IE8.md about why we use handleEvent
/**
* The event listener object we implement
*/
handleEvent(eventName: Event): void {
// we don't need to do anything with the eventName
// as we want all events to fire the same actions
let currentTime = (new Date).getTime();
this.setLastResetTimeStamp(currentTime);
this.throttle();
}
/**
* Starts the timer
*/
start(): void {
this.setLastResetTimeStamp((new Date()).getTime());
this.startPrivate(this.internalTimeoutTime);
this.status = InactivityCountdownTimerStatus.started;
}
/**
* Clears the timer
*/
stop(): void {
this.window.clearInterval(this.idleIntervalId);
this.status = InactivityCountdownTimerStatus.stopped;
}
/**
* **You must call cleanup** before you delete the object.
* As the timer in the class is calling a method on itself
* it will not be garbage collected if you just delete it.
*/
cleanup(): void {
this.detachEventListeners();
// added in detectAndAssignLocalStorage for ie11
this.window.removeEventListener('storage', function() {});
this.window.clearTimeout(this.throttleTimeoutId);
this.stop();
}
private ensureReasonableTimings(params?: IInactivityConfig) {
if(params && notUndefined(params.idleTimeoutTime) && !isNumberNotNan(params.idleTimeoutTime)) {
this.logger.log('idleTimeoutTime passed was not a number, setting to 30 minutes');
// ideally we never get here, but just in case some type of sensible timeout fallback
this.idleTimeoutTime = 30 * 60 * 1000;
}
if(params && isNumberNotNan(params.startCountDownTimerAt)) {
// if start count down timer is present make sure its a number and less than idleTimeoutTime
if(!(params.startCountDownTimerAt < this.idleTimeoutTime)) {
this.logger.log('startCountdown time must be smaller than idleTimeoutTime, setting to idleTimeoutTime');
this.startCountDownTimerAt = this.idleTimeoutTime;
this.internalTimeoutTime = 1000; // start the countdown
} else {
this.startCountDownTimerAt = params.startCountDownTimerAt;
this.internalTimeoutTime = this.idleTimeoutTime - this.startCountDownTimerAt;
}
} else {
// don't use count down timer
this.startCountDownTimerAt = 0;
this.internalTimeoutTime = this.idleTimeoutTime;
}
if (params && isNumberNotNan(params.throttleDuration)) {
const maxThrottleTime = Math.floor(this.internalTimeoutTime / 5);
params.throttleDuration;
if (params.throttleDuration > maxThrottleTime) {
this.logger.log(`throttle time must be smaller than 1/5th timeout time: ${this.internalTimeoutTime} setting to ${maxThrottleTime}ms`)
this.throttleDuration = maxThrottleTime;
}
}
}
private throttle() {
if (this.throttleDuration > 0 ) {
this.detachEventListeners();
this.throttleTimeoutId = this.window.setTimeout(() => {
this.attacheEventListeners();
}, this.throttleDuration);
}
}
private attacheEventListeners() {
// attach events that will rest the timers
// this ends up calling the this.handleEvent function
// see README.md for more on why we are passing 'this'
for(let i=0; i < this.resetEvents.length; i++) {
this.document.addEventListener(this.resetEvents[i], this, false)
}
for(let i=0; i < this.windowResetEvents.length; i++) {
this.window.addEventListener(this.windowResetEvents[i], this, false);
}
}
private detachEventListeners() {
for(let i=0; i < this.resetEvents.length; i++) {
this.document.removeEventListener(this.resetEvents[i], this, false)
}
for(let i=0; i < this.windowResetEvents.length; i++) {
this.window.removeEventListener(this.windowResetEvents[i], this, false);
}
}
private startPrivate(precision: number) {
this.currentTimerPrecision = precision;
this.idleIntervalId = this.window.setInterval(() => {
this.checkIdleTime();
}, precision);
}
private resetTimer(precision: number){
this.stop();
this.startPrivate(precision);
}
private timeout(): void {
this.cleanup();
if(this.timeoutCallback){
this.timeoutCallback();
}
}
private checkIdleTime(){
let currentTimeStamp = (new Date()).getTime();
let lastResetTimeStamp = this.getLastResetTimeStamp();
let milliSecondDiff = currentTimeStamp - lastResetTimeStamp;
let timeRemaining = this.idleTimeoutTime - milliSecondDiff;
this.checkTimerPrecision(timeRemaining);
this.handleCountDown(timeRemaining);
if(milliSecondDiff >= this.idleTimeoutTime) {
this.timeout();
}
}
private handleCountDown(timeRemaining: number) {
let inCountDownTimeFrame = (timeRemaining <= this.startCountDownTimerAt);
if(inCountDownTimeFrame && this.countDownCallback){
this.countingDown = true;
this.countDownCallback(Math.abs(Math.ceil(timeRemaining/1000)));
} else if (!inCountDownTimeFrame && this.countingDown) {
if(this.countDownCancelledCallback) {
this.countDownCancelledCallback();
}
this.countingDown = false;
}
}
private checkTimerPrecision(timeRemaining: number) {
// when we are counting down we want to
// increase the interval precision to seconds
if(timeRemaining <= this.startCountDownTimerAt){
// don't change if it's already seconds
if(this.currentTimerPrecision !== 1000) {
this.resetTimer(1000)
}
}
else {
// the js timer can be out by milliseconds, we need to set the timer to:
// the time remaining - when we start the count down timer
// eg 15 sec timeout, 10 sec countdown, time remaining 14345 secs
// timeout should be 4345 secs
let nextTimeoutWhen = timeRemaining - this.startCountDownTimerAt;
this.resetTimer(nextTimeoutWhen);
}
}
private getLastResetTimeStamp(): number {
if(this.localStorage){
const lastResetTimeStampString = this.localStorage.getItem(this.localStorageKey);
const lsLastResetTimeStamp = parseInt(lastResetTimeStampString, 10);
if (lsLastResetTimeStamp) {
return lsLastResetTimeStamp;
}
}
return this.lastResetTimeStamp
}
private setLastResetTimeStamp(timestamp: number): void {
if(this.localStorage){
this.localStorage.setItem(this.localStorageKey, timestamp.toString());
}
this.lastResetTimeStamp = timestamp;
}
private detectAndAssignLocalStorage(_localStorage?: Storage | null): Storage {
if (localStorageOrNull(_localStorage)) {
return _localStorage;
}
// this fixes a bug in ie11 where the local storage does not sync
// https://connect.microsoft.com/IE/feedback/details/812563/ie-11-local-storage-synchronization-issues
this.window.addEventListener('storage', function() {}); // effectively a no-op
let uid: string = (new Date()).getTime().toString() + 'detectAndAssignLocalStorage';
let storage: Storage = localStorage;
let result: boolean;
try {
storage.setItem(uid, uid);
result = storage.getItem(uid) === uid;
storage.removeItem(uid);
return result && storage;
} catch(exception) {
this.logger.log('LOCAL STORAGE IS NOT AVAILABLE FOR SYNCING TIMEOUT ACROSS TABS', exception);
return null;
}
}
}
function localStorageOrNull(value: Storage | null): boolean {
if (value === null) {
return true;
}
return Boolean(value);
}
function notUndefined(value: any) {
return value !== undefined;
}
export function isNumberNotNan(value: any): boolean {
//isNaN not Number.isNan for ie9
return typeof value === 'number' && !isNaN(value)
}