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}