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}