001package arez.dom; 002 003import akasha.AddEventListenerOptions; 004import akasha.EventListener; 005import akasha.TimerHandler; 006import akasha.WindowGlobal; 007import arez.ArezContext; 008import arez.Disposable; 009import arez.Task; 010import arez.annotations.Action; 011import arez.annotations.ArezComponent; 012import arez.annotations.ContextRef; 013import arez.annotations.Feature; 014import arez.annotations.Memoize; 015import arez.annotations.Observable; 016import arez.annotations.OnActivate; 017import arez.annotations.OnDeactivate; 018import arez.annotations.PostConstruct; 019import arez.annotations.PreDispose; 020import java.util.Arrays; 021import java.util.HashSet; 022import java.util.Set; 023import javax.annotation.Nonnull; 024 025/** 026 * An Arez browser component that tracks when the user is idle. A user is considered idle if they have not 027 * interacted with the browser for a specified amount of time. The component declares state that tracks when 028 * the user is "idle". A user is considered idle if they have not interacted with the browser 029 * for a specified amount of time. 030 * 031 * <p>Application code can observe the idle state via accessing {@link #isIdle()}. 032 * Typically this is done in a tracking transaction such as those defined by autorun.</p> 033 * 034 * <p>The "amount of time" is defined by the Observable value "timeout" accessible via 035 * {@link #getTimeout()} and mutable via {@link #setTimeout(long)}.</p> 036 * 037 * <p>The "not interacted with the browser" is detected by listening for interaction 038 * events on the browser. The list of events that the model listens for is controlled via 039 * {@link #getEvents()} and {@link #setEvents(Set)}. It should be noted that if 040 * there is no observer observing the idle state then the model will remove listeners 041 * so as not to have any significant performance impact.</p> 042 * 043 * <p>A very simple example</p> 044 * <pre>{@code 045 * import com.google.gwt.core.client.EntryPoint; 046 * import akasha.Console; 047 * import arez.Arez; 048 * import arez.dom.IdleStatus; 049 * 050 * public class IdleStatusExample 051 * implements EntryPoint 052 * { 053 * public void onModuleLoad() 054 * { 055 * final IdleStatus idleStatus = IdleStatus.create(); 056 * Arez.context().autorun( () -> { 057 * final String message = "Interaction Status: " + ( idleStatus.isIdle() ? "Idle" : "Active" ); 058 * Console.log( message ); 059 * } ); 060 * } 061 * } 062 * }</pre> 063 */ 064@ArezComponent( requireId = Feature.DISABLE ) 065public abstract class IdleStatus 066{ 067 private static final long DEFAULT_TIMEOUT = 2000L; 068 @Nonnull 069 private final TimerHandler _timeoutCallback = this::onTimeout; 070 @Nonnull 071 private final EventListener _listener = e -> tryResetLastActivityTime(); 072 @Nonnull 073 private Set<String> _events = 074 new HashSet<>( Arrays.asList( "keydown", "touchstart", "scroll", "mousemove", "mouseup", "mousedown", "wheel" ) ); 075 /** 076 * True if an Observer is watching idle state. 077 */ 078 private boolean _active; 079 /** 080 * The id of timeout scheduled action, 0 if none set. 081 */ 082 private int _timeoutId; 083 084 /** 085 * Create an instance of this model. 086 * 087 * @return an instance of IdleStatus. 088 */ 089 @Nonnull 090 public static IdleStatus create() 091 { 092 return create( DEFAULT_TIMEOUT ); 093 } 094 095 /** 096 * Create an instance of this model. 097 * 098 * @param timeout the duration to after activity before becoming idle. 099 * @return an instance of IdleStatus. 100 */ 101 @Nonnull 102 public static IdleStatus create( final long timeout ) 103 { 104 return new Arez_IdleStatus( timeout ); 105 } 106 107 IdleStatus() 108 { 109 } 110 111 @ContextRef 112 abstract ArezContext context(); 113 114 @PostConstruct 115 void postConstruct() 116 { 117 resetLastActivityTime(); 118 } 119 120 @PreDispose 121 void preDispose() 122 { 123 cancelTimeout(); 124 } 125 126 /** 127 * Return true if the user is idle. 128 * 129 * @return true if the user is idle, false otherwise. 130 */ 131 @Memoize 132 public boolean isIdle() 133 { 134 if ( isRawIdle() ) 135 { 136 return true; 137 } 138 else 139 { 140 final int timeToWait = getTimeToWait(); 141 if ( timeToWait > 0 ) 142 { 143 if ( 0 == _timeoutId ) 144 { 145 scheduleTimeout( timeToWait ); 146 } 147 return false; 148 } 149 else 150 { 151 return true; 152 } 153 } 154 } 155 156 @OnActivate 157 void onIdleActivate() 158 { 159 _active = true; 160 _events.forEach( e -> WindowGlobal.addEventListener( e, _listener, AddEventListenerOptions.of().passive( true ) ) ); 161 } 162 163 @OnDeactivate 164 void onIdleDeactivate() 165 { 166 _active = false; 167 _events.forEach( e -> WindowGlobal.removeEventListener( e, _listener ) ); 168 } 169 170 /** 171 * Short cut observable field checked after idle state is confirmed. 172 */ 173 @Observable 174 abstract void setRawIdle( boolean rawIdle ); 175 176 abstract boolean isRawIdle(); 177 178 /** 179 * Return the duration for which no events should be received for the idle condition to be triggered. 180 * 181 * @return the timeout. 182 */ 183 @Observable( initializer = Feature.ENABLE ) 184 public abstract long getTimeout(); 185 186 /** 187 * Set the timeout. 188 * 189 * @param timeout the timeout. 190 */ 191 public abstract void setTimeout( long timeout ); 192 193 /** 194 * Return the set of events to listen to. 195 * 196 * @return the set of events. 197 */ 198 @Nonnull 199 @Observable 200 public Set<String> getEvents() 201 { 202 return _events; 203 } 204 205 /** 206 * Specify the set of events to listen to. 207 * If the model is already active, the listeners will be updated to reflect the new events. 208 * 209 * @param events the set of events. 210 */ 211 public void setEvents( @Nonnull final Set<String> events ) 212 { 213 final Set<String> oldEvents = _events; 214 _events = new HashSet<>( events ); 215 updateListeners( oldEvents ); 216 } 217 218 /** 219 * Synchronize listeners against the dom based on new events. 220 */ 221 private void updateListeners( @Nonnull final Set<String> oldEvents ) 222 { 223 if ( _active ) 224 { 225 //Remove any old events 226 oldEvents.stream(). 227 filter( e -> !_events.contains( e ) ). 228 forEach( e -> WindowGlobal.removeEventListener( e, _listener ) ); 229 // Add any new events 230 _events.stream(). 231 filter( e -> !oldEvents.contains( e ) ). 232 forEach( e -> WindowGlobal.addEventListener( e, _listener ) ); 233 } 234 } 235 236 /** 237 * Return the time at which the last monitored event was received. 238 * 239 * @return the time at which the last event was received. 240 */ 241 @Observable 242 public abstract long getLastActivityAt(); 243 244 abstract void setLastActivityAt( long lastActivityAt ); 245 246 private int getTimeToWait() 247 { 248 return (int) ( getLastActivityAt() + getTimeout() - System.currentTimeMillis() ); 249 } 250 251 private void cancelTimeout() 252 { 253 WindowGlobal.clearTimeout( _timeoutId ); 254 _timeoutId = 0; 255 } 256 257 private void scheduleTimeout( final int timeToWait ) 258 { 259 _timeoutId = WindowGlobal.setTimeout( _timeoutCallback, timeToWait ); 260 } 261 262 @Action 263 void onTimeout() 264 { 265 _timeoutId = 0; 266 final int timeToWait = getTimeToWait(); 267 if ( timeToWait > 0 ) 268 { 269 scheduleTimeout( timeToWait ); 270 } 271 else 272 { 273 setRawIdle( true ); 274 } 275 } 276 277 void tryResetLastActivityTime() 278 { 279 if ( context().isTransactionActive() ) 280 { 281 // This can be called anytime an event occurs ... which can 282 // actually occur during the middle of a transaction (i.e. a browser exception event) 283 // So if we are in the middle of a transaction, just trigger its execution for later. 284 context().task( this::doResetLastActivityTime, Task.Flags.DISPOSE_ON_COMPLETE ); 285 } 286 else 287 { 288 doResetLastActivityTime(); 289 } 290 } 291 292 void doResetLastActivityTime() 293 { 294 // As the tryResetLastActivityTime can be scheduled later, 295 // it is possible that this will be invoked after the object has been disposed 296 if( Disposable.isNotDisposed( this ) ) 297 { 298 resetLastActivityTime(); 299 } 300 } 301 302 @Action 303 void resetLastActivityTime() 304 { 305 setRawIdle( false ); 306 setLastActivityAt( System.currentTimeMillis() ); 307 } 308}