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