diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2017-03-22 12:17:44 +0000 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2017-03-22 15:06:15 +0000 |
commit | 964f9283146f1dc3983ce073782ff7e368775ddf (patch) | |
tree | 56639cab1a0c66108753a9a59c27b92a3ba1a27b | |
parent | 74459e0c3696f0bae1095604c2c4f35aa35ee043 (diff) | |
download | gitlab-ce-29569-eyeballs-off.tar.gz |
Port eyeballs lib into GitLab and ES629569-eyeballs-off
8 files changed, 316 insertions, 0 deletions
diff --git a/app/assets/javascripts/lib/eyeballs_off/eyeballs_activity.js b/app/assets/javascripts/lib/eyeballs_off/eyeballs_activity.js new file mode 100644 index 00000000000..0605e03e107 --- /dev/null +++ b/app/assets/javascripts/lib/eyeballs_off/eyeballs_activity.js @@ -0,0 +1,69 @@ +import _ from 'underscore'; +import { addEventListener } from './utils/passive_event_listener'; + +const INACTIVITY = 60 * 1000; /* One minute */ +const INACTIVITY_POLL = 10 * 1000; /* 10 seconds */ + +export default class EyeballsActivity { + constructor(callback) { + this.callback = callback; + this.inactivityTimer = null; + this.inactive = null; + this.lastUserInteraction = null; + + const debouncedInteractionTracking = _.debounce(this.registerInteraction.bind(this), 500); + + addEventListener(document, 'keydown', debouncedInteractionTracking); + addEventListener(document, 'mousemove', debouncedInteractionTracking); + addEventListener(document, 'touchstart', debouncedInteractionTracking); + addEventListener(window, 'scroll', debouncedInteractionTracking); + + // Default to there being activity + this.setInactive(false); + } + + setInactive(isInactive) { + if (this.inactive === isInactive) return; + + this.inactive = isInactive; + + if (isInactive) { + this.stopInactivityPoller(); + } else { + this.lastUserInteraction = Date.now(); + this.startInactivityPoller(); + } + + this.callback(!isInactive); + } + + // User did something + registerInteraction() { + this.setInactive(false); + } + + /** + * This timer occassionally checks whether the user has performed any + * interactions since the last time it was called. + * While being careful to deal with the computer sleeping + */ + startInactivityPoller() { + if (this.inactivityTimer) return; + this.inactivityTimer = setInterval(() => { + // This is a long timeout, so it could possibly be delayed by + // the user pausing the application. Therefore just wait for one + // more period for activity to start again... + setTimeout(() => { + if (Date.now() - this.lastUserInteraction > (INACTIVITY - INACTIVITY_POLL)) { + this.setInactive(true); + } + }, 5); + }, INACTIVITY_POLL); + } + + stopInactivityPoller() { + if (!this.inactivityTimer) return; + clearTimeout(this.inactivityTimer); + this.inactivityTimer = null; + } +} diff --git a/app/assets/javascripts/lib/eyeballs_off/eyeballs_detector.js b/app/assets/javascripts/lib/eyeballs_off/eyeballs_detector.js new file mode 100644 index 00000000000..92083b25a4e --- /dev/null +++ b/app/assets/javascripts/lib/eyeballs_off/eyeballs_detector.js @@ -0,0 +1,62 @@ + +import _ from 'underscore'; +// var Backbone = require('backbone'); +// var debug = require('debug-proxy')('app:eyeballs:detector'); + +import EyeballsActivity from './eyeballs_activity'; +import eyeballsFocus from './eyeballs_focus'; +import EyeballsVisibility from './eyeballs_visibility'; + +const events = {}; +// _.extend(events, Backbone.Events); + +export default class EyeballsDetector { + constructor() { + this.hasVisibility = true; + this.hasFocus = true; + this.hasActivity = true; + this.eyesOnState = true; + + this.activityMonitor = new EyeballsActivity((signal) => { + // debug('Activity signal: %s', signal); + this.hasActivity = signal; + this.update(); + }); + + this.eyeballsVisibility = new EyeballsVisibility((signal) => { + // debug('Visibility signal: %s', signal); + this.hasVisibility = signal; + this.update(); + }); + + eyeballsFocus((signal) => { + // debug('Focus signal: %s', signal); + this.hasFocus = signal; + if (signal) { + // Focus means the user is active... + this.activityMonitor.setInactive(false); + } else { + this.activityMonitor.setInactive(true); + } + this.update(); + }); + } + + update() { + const newValue = this.hasVisibility && this.hasFocus && this.hasActivity; + if (newValue === this.eyesOnState) return; + + this.eyesOnState = newValue; + // debug('Eyeballs change: %s', newValue); + + this.events.trigger('change', this.eyesOnState); + } + + getEyeballs() { + return this.eyesOnState; + } + + forceActivity() { + this.activityMonitor.setInactive(false); + } +} diff --git a/app/assets/javascripts/lib/eyeballs_off/eyeballs_focus.js b/app/assets/javascripts/lib/eyeballs_off/eyeballs_focus.js new file mode 100644 index 00000000000..e2843881a30 --- /dev/null +++ b/app/assets/javascripts/lib/eyeballs_off/eyeballs_focus.js @@ -0,0 +1,12 @@ +import { addEventListener } from './utils/passive_event_listener'; + +export default (callback) => { + // Add listeners to the base window + addEventListener(window, 'focus', () => { + callback(true); + }); + + addEventListener(window, 'blur', () => { + callback(false); + }); +}; diff --git a/app/assets/javascripts/lib/eyeballs_off/eyeballs_visibility.js b/app/assets/javascripts/lib/eyeballs_off/eyeballs_visibility.js new file mode 100644 index 00000000000..54e5944f8c2 --- /dev/null +++ b/app/assets/javascripts/lib/eyeballs_off/eyeballs_visibility.js @@ -0,0 +1,21 @@ +import { events, isHidden } from './utils/page_visibility'; +import { addEventListener } from './utils/passive_event_listener'; + +export default (callback) => { + // TODO: consider removing these now that we're using + // pageVisibility + addEventListener(window, 'pageshow', () => { + callback(true); + }); + addEventListener(window, 'pagehide', () => { + callback(false); + }); + + events.on('change', () => { + if (isHidden()) { + callback(false); + } else { + callback(true); + } + }); +}; diff --git a/app/assets/javascripts/lib/eyeballs_off/utils/page_visibility.js b/app/assets/javascripts/lib/eyeballs_off/utils/page_visibility.js new file mode 100644 index 00000000000..cdc33108154 --- /dev/null +++ b/app/assets/javascripts/lib/eyeballs_off/utils/page_visibility.js @@ -0,0 +1,44 @@ + +const eventsObject = {}; + +_.extend(eventsObject, Backbone.Events); + +const PREFIXES = ['moz', 'ms', 'webkit']; + +const findPrefix = () => { + if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support + return { + prop: 'hidden', + eventName: 'visibilitychange', + }; + } + + for (let i = 0; i < PREFIXES.length; i += 1) { + const prefix = PREFIXES[i]; + if (typeof document[`${prefix} Hidden`] !== 'undefined') { + return { + prop: `${prefix} Hidden`, + eventName: `${prefix} visibilitychange`, + }; + } + } +}; + +const prefix = findPrefix(); +const prop = prefix && prefix.prop; +const eventName = prefix && prefix.eventName; + +const handleVisibilityChange = () => { + eventsObject.trigger('change'); +}; + +export const isHidden = () => { + if (!prop) return undefined; + return document[prop]; +}; + +if (eventName) { + document.addEventListener(eventName, handleVisibilityChange, false); +} + +export const events = eventsObject; diff --git a/app/assets/javascripts/lib/eyeballs_off/utils/passive_event_listener.js b/app/assets/javascripts/lib/eyeballs_off/utils/passive_event_listener.js new file mode 100644 index 00000000000..7b0285d4f86 --- /dev/null +++ b/app/assets/javascripts/lib/eyeballs_off/utils/passive_event_listener.js @@ -0,0 +1,43 @@ +/** + * Tests for passive scroll support + * Copied from https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md + */ +let supportsPassiveOption = false; +try { + const opts = Object.defineProperty({}, 'passive', { + get: () => { + supportsPassiveOption = true; + }, + }); + window.addEventListener('test', null, opts); +} catch (e) { + /* */ +} + +/** + * Attempts to add a passive scroll listener if possible, + * otherwise adds a non-capture listeners + */ +export const addEventListener = (target, type, handler) => { + let optionsOrCapture; + + if (supportsPassiveOption) { + optionsOrCapture = { passive: true }; + } else { + optionsOrCapture = false; + } + + target.addEventListener(type, handler, optionsOrCapture); +}; + +export const removeEventListener = (target, type, handler) => { + let optionsOrCapture; + + if (supportsPassiveOption) { + optionsOrCapture = { passive: true }; + } else { + optionsOrCapture = false; + } + + target.removeEventListener(type, handler, optionsOrCapture); +}; diff --git a/app/assets/javascripts/lib/eyeballs_off/utils/raf.js b/app/assets/javascripts/lib/eyeballs_off/utils/raf.js new file mode 100644 index 00000000000..a3c6003734b --- /dev/null +++ b/app/assets/javascripts/lib/eyeballs_off/utils/raf.js @@ -0,0 +1,26 @@ +/* eslint-disable no-mixed-operators */ +/** + * Request Animation Frame shim + * + * The window.requestAnimationFrame() method + * tells the browser that you wish to perform + * an animation and requests that the browser + * call a specified function to update an animation + * before the next repaint. + */ +const nativeRaf = window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame; + +const nativeCancel = window.cancelAnimationFrame || + window.webkitCancelAnimationFrame || + window.mozCancelAnimationFrame; + +const shim = callback => window.setTimeout(callback, 1000 / 60); + +const shimCancel = (timeoutId) => { + window.clearTimeout(timeoutId); +}; + +export default nativeRaf && nativeRaf.bind(window) || shim; +export const cancel = nativeCancel && nativeCancel.bind(window) || shimCancel; diff --git a/app/assets/javascripts/lib/eyeballs_off/utils/raf_utils.js b/app/assets/javascripts/lib/eyeballs_off/utils/raf_utils.js new file mode 100644 index 00000000000..5dfec813723 --- /dev/null +++ b/app/assets/javascripts/lib/eyeballs_off/utils/raf_utils.js @@ -0,0 +1,39 @@ +import raf, { cancel } from './raf'; + +/* Animation-frame frequency debounce */ +export const debounce = (fn, context) => { + let existing; + return () => { + if (existing) cancel(existing); + + existing = raf(() => { + existing = undefined; + fn.call(context); + }); + }; +}; + +/* Only allow one instantiation per animation frame, on the trailing edge */ +export const throttle = (fn, context) => { + let existing; + + return () => { + if (existing) return; + existing = raf(() => { + existing = undefined; + fn.call(context); + }); + }; +}; + +/* Perform an operation on each animation frame for the specified duration */ +export const intervalUntil = (fn, ms) => { + const until = Date.now() + ms; + function next() { + fn(); + if (Date.now() < until) { + raf(next); + } + } + raf(next); +}; |