diff --git a/src/plugins/FileUpload.js b/src/plugins/FileUpload.js index 4f773d0..bc455ac 100644 --- a/src/plugins/FileUpload.js +++ b/src/plugins/FileUpload.js @@ -277,6 +277,13 @@ module.exports = class FileUpload extends OverlayPlugin { textProgress.innerHTML = this.i18n.UPLOADER_INSTALLING || 'Installing...'; } else if (data[0] === 'ready' && data.length >= 2) { if (data[1].indexOf('opengapps') !== -1) { + this.instance.store.dispatch({ + type: 'ADD_TRACKED_EVENT', + payload: { + category: 'opengapps', + type: 'installed', + }, + }); this.updateOpenGAppsStatus(true); } diff --git a/src/plugins/GamepadManager.js b/src/plugins/GamepadManager.js index ef23562..15790a0 100644 --- a/src/plugins/GamepadManager.js +++ b/src/plugins/GamepadManager.js @@ -262,6 +262,13 @@ module.exports = class GamepadManager { * @param {GamepadEvent} event raw event coming from the browser Gamepad API */ onGamepadConnected(event) { + this.instance.store.dispatch({ + type: 'ADD_TRACKED_EVENT', + payload: { + category: 'gamepad', + action: 'plugged', + }, + }); const customEvent = new CustomEvent('gm-gamepadConnected', {detail: this.parseGamepad(event.gamepad)}); window.dispatchEvent(customEvent); } @@ -271,6 +278,13 @@ module.exports = class GamepadManager { * @param {GamepadEvent} event raw event coming from the browser Gamepad API */ onGamepadDisconnected(event) { + this.instance.store.dispatch({ + type: 'ADD_TRACKED_EVENT', + payload: { + category: 'gamepad', + action: 'unplugged', + }, + }); const customEvent = new CustomEvent('gm-gamepadDisconnected', {detail: this.parseGamepad(event.gamepad)}); window.dispatchEvent(customEvent); this.stopListeningInputs(event.gamepad.index); diff --git a/src/plugins/KeyboardEvents.js b/src/plugins/KeyboardEvents.js index 68f3465..45f4d28 100644 --- a/src/plugins/KeyboardEvents.js +++ b/src/plugins/KeyboardEvents.js @@ -58,13 +58,16 @@ module.exports = class KeyboardEvents { this.isListenerAdded = false; this.currentlyPressedKeys = new Map(); - this.instance.store.subscribe(({isKeyboardEventsEnabled}) => { - if (isKeyboardEventsEnabled) { - this.addKeyboardCallbacks(); - } else { - this.removeKeyboardCallbacks(); - } - }); + this.instance.store.subscribe( + ({isKeyboardEventsEnabled}) => { + if (isKeyboardEventsEnabled) { + this.addKeyboardCallbacks(); + } else { + this.removeKeyboardCallbacks(); + } + }, + ['isKeyboardEventsEnabled'], + ); // activate the plugin listening this.instance.store.dispatch({type: 'KEYBOARD_EVENTS_ENABLED', payload: true}); diff --git a/src/plugins/KeyboardMapping.js b/src/plugins/KeyboardMapping.js index 9d2cbf1..f11e543 100644 --- a/src/plugins/KeyboardMapping.js +++ b/src/plugins/KeyboardMapping.js @@ -157,13 +157,16 @@ module.exports = class KeyboardMapping { ); // pause the listeners when dialog is open and plugin isActive - this.instance.store.subscribe(({overlay: {isOpen}}) => { - if (isOpen && this.state.isActive) { - this.state.isPaused = true; - } else if (!isOpen && this.state.isPaused) { - this.state.isPaused = false; - } - }); + this.instance.store.subscribe( + ({overlay: {isOpen}}) => { + if (isOpen && this.state.isActive) { + this.state.isPaused = true; + } else if (!isOpen && this.state.isPaused) { + this.state.isPaused = false; + } + }, + ['overlay.isOpen'], + ); // Display widget this.renderToolbarButton(); @@ -190,12 +193,16 @@ module.exports = class KeyboardMapping { activatePlugin() { if (this.state.isActive) { - this.toolbarBtnImage.classList.add('gm-active'); + if (this.toolbarBtnImage) { + this.toolbarBtnImage.classList.add('gm-active'); + } this.addKeyboardCallbacks(); this.instance.store.dispatch({type: 'KEYBOARD_EVENTS_ENABLED', payload: false}); this.instance.store.dispatch({type: 'MOUSE_EVENTS_ENABLED', payload: false}); } else { - this.toolbarBtnImage.classList.remove('gm-active'); + if (this.toolbarBtnImage) { + this.toolbarBtnImage.classList.remove('gm-active'); + } this.removeKeyboardCallbacks(); this.instance.store.dispatch({type: 'KEYBOARD_EVENTS_ENABLED', payload: !this.state.isPaused}); this.instance.store.dispatch({type: 'MOUSE_EVENTS_ENABLED', payload: !this.state.isPaused}); diff --git a/src/plugins/MouseEvents.js b/src/plugins/MouseEvents.js index 4b46727..79cfe78 100644 --- a/src/plugins/MouseEvents.js +++ b/src/plugins/MouseEvents.js @@ -22,13 +22,16 @@ module.exports = class MouseEvents { this.boundEventListener = this.releaseAtPreviousPositionEvent.bind(this); this.removeMouseUpListener = () => {}; - this.instance.store.subscribe(({isMouseEventsEnabled}) => { - if (isMouseEventsEnabled) { - this.addMouseCallbacks(); - } else { - this.removeMouseCallbacks(); - } - }); + this.instance.store.subscribe( + ({isMouseEventsEnabled}) => { + if (isMouseEventsEnabled) { + this.addMouseCallbacks(); + } else { + this.removeMouseCallbacks(); + } + }, + ['isMouseEventsEnabled'], + ); this.mouseCallbacks = []; diff --git a/src/plugins/util/OverlayPlugin.js b/src/plugins/util/OverlayPlugin.js index 601a369..ea7cabb 100644 --- a/src/plugins/util/OverlayPlugin.js +++ b/src/plugins/util/OverlayPlugin.js @@ -19,13 +19,16 @@ class OverlayPlugin { this.instance = instance; // Listen for close trigger - this.instance.store.subscribe(({overlay}) => { - if (overlay.widgetOpened.includes(this.overlayID)) { - this.openOverlay(); - } else { - this.closeOverlay(); - } - }); + this.instance.store.subscribe( + ({overlay}) => { + if (overlay.widgetOpened.includes(this.overlayID)) { + this.openOverlay(); + } else { + this.closeOverlay(); + } + }, + ['overlay.widgetOpened'], + ); // Attach listener for first object created only if (!OverlayPlugin.hasBeenCalled) { @@ -58,6 +61,14 @@ class OverlayPlugin { if (this.toolbarBtnImage) { this.toolbarBtnImage.classList.add('gm-active'); } + this.instance.store.dispatch({ + type: 'ADD_TRACKED_EVENT', + payload: { + category: 'widget', + action: 'open', + name: this.constructor.name, + }, + }); } /** diff --git a/src/store/index.js b/src/store/index.js index ca59634..0f88529 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -11,9 +11,13 @@ const initialState = { }, isKeyboardEventsEnabled: false, isMouseEventsEnabled: false, + trackedEvents: { + isActive: false, + events: [], + }, }; -const createStore = (instance, reducer, state) => { +const createStore = (instance, reducer) => { const listeners = []; const getters = { @@ -21,18 +25,72 @@ const createStore = (instance, reducer, state) => { instance.store.state.overlay.isOpen && instance.store.state.overlay.widgetOpened.includes(overlayID), }; - const dispatch = (action) => { - instance.store.state = reducer(instance.store.state, action); - listeners.forEach(({cb}) => { - cb(); + const hasChanged = (changedKeys, keyPath) => { + const keys = keyPath.split('.'); + return changedKeys.some((changedKey) => { + const changedKeyParts = changedKey.split('.'); + return keys.every((key, index) => key === changedKeyParts[index]); + }); + }; + + const findChangedKeys = (newState, oldState, path = []) => { + let changedKeys = []; + + const isObject = (obj) => obj && typeof obj === 'object'; + + for (const key in newState) { + const fullPath = [...path, key].join('.'); + + if (!Object.prototype.hasOwnProperty.call(oldState, key)) { + changedKeys.push(fullPath); + } else if (isObject(newState[key]) && isObject(oldState[key])) { + if (Array.isArray(newState[key]) && Array.isArray(oldState[key])) { + if (newState[key].length !== oldState[key].length) { + changedKeys.push(fullPath); + } else { + let arrayHasChanged = false; + for (let i = 0; i < newState[key].length; i++) { + if (newState[key][i] !== oldState[key][i]) { + arrayHasChanged = true; + } + } + if (arrayHasChanged) { + changedKeys.push(fullPath); + } + } + } else { + changedKeys = changedKeys.concat(findChangedKeys(newState[key], oldState[key], [...path, key])); + } + } else if (newState[key] !== oldState[key]) { + changedKeys.push(fullPath); + } + } + + return changedKeys; + }; + + const notifyListeners = (changedKeys) => { + listeners.forEach(({keys, cb}) => { + if (keys.length === 0 || keys.some((key) => hasChanged(changedKeys, key))) { + // send a copy of the store's state, in order to avoid mutation of the store + cb({...instance.store.state}); + } }); }; - const subscribe = (listener) => { + const dispatch = (action) => { + const previousState = JSON.parse(JSON.stringify(instance.store.state)); + instance.store.state = reducer({...instance.store.state}, action); + const changedKeys = findChangedKeys(instance.store.state, previousState); + notifyListeners(changedKeys); + }; + + const subscribe = (listener, keys = []) => { const uid = generateUID(); listeners.push({ uid, - cb: () => listener(instance.store.state), + keys, + cb: listener, }); const unsubscribe = () => { @@ -45,68 +103,64 @@ const createStore = (instance, reducer, state) => { return unsubscribe; }; - instance.store = {state, dispatch, subscribe, getters}; + instance.store = {state: initialState, dispatch, subscribe, getters}; }; const reducer = (state, action) => { - let newState = state; switch (action.type) { case 'WEBRTC_CONNECTION_READY': - newState = {...state, isWebRTCConnectionReady: action.payload}; + state.isWebRTCConnectionReady = action.payload; break; case 'KEYBOARD_EVENTS_ENABLED': - newState = {...state, isKeyboardEventsEnabled: action.payload}; + state.isKeyboardEventsEnabled = action.payload; break; case 'MOUSE_EVENTS_ENABLED': - newState = {...state, isMouseEventsEnabled: action.payload}; + state.isMouseEventsEnabled = action.payload; break; case 'OVERLAY_OPEN': // eslint-disable-next-line no-case-declarations const {overlayID, toOpen} = action.payload; if (toOpen) { - newState = { - ...state, - overlay: { - isOpen: true, - widgetOpened: [overlayID], - /* - * to open several widgets at the same time - * widgetOpened: [...state.overlay.widgetOpened, overlayID], - */ - }, - isKeyboardEventsEnabled: false, - isMouseEventsEnabled: false, - }; - break; + state.overlay.isOpen = true; + /* + * to open several widgets at the same time + * widgetOpened: [...state.overlay.widgetOpened, overlayID], + */ + state.overlay.widgetOpened = [overlayID]; + state.isKeyboardEventsEnabled = false; + state.isMouseEventsEnabled = false; + } else { + state.overlay.isOpen = false; + state.overlay.widgetOpened = []; + state.isKeyboardEventsEnabled = true; + state.isMouseEventsEnabled = true; } - // eslint-disable-next-line no-case-declarations - const widgetOpened = []; - /* - * to open several widgets at the same time - * const widgetOpened = state.overlay.widgetOpened.filter((widgetId) => widgetId !== overlayID); - */ - newState = { - ...state, - overlay: { - isOpen: widgetOpened.length > 0, - widgetOpened, - }, - isKeyboardEventsEnabled: true, - isMouseEventsEnabled: true, - }; + break; + case 'ENABLE_TRACKED_EVENTS': + state.trackedEvents.isActive = action.payload; + break; + case 'ADD_TRACKED_EVENT': + if (!state.trackedEvents.isActive) { + return state; + } + state.trackedEvents.events.push(action.payload); + + break; + case 'FLUSH_TRACKED_EVENTS': + state.trackedEvents.events.length = 0; break; default: log.debug('Store not updated, action type :', action.type, ' unknown'); break; } - log.debug('Store updated below type - payload - result', action.type, action.payload, newState); + log.debug('Store updated below type - payload - result', action.type, action.payload, state); - return newState; + return state; }; const store = (instance) => { - createStore(instance, reducer, initialState); + createStore(instance, reducer); }; module.exports = store; diff --git a/tests/unit/apiManager.test.js b/tests/unit/apiManager.test.js new file mode 100644 index 0000000..d2084c7 --- /dev/null +++ b/tests/unit/apiManager.test.js @@ -0,0 +1,57 @@ +'use strict'; + +const ApiManager = require('../../src/APIManager'); +const Instance = require('../mocks/DeviceRenderer'); +const Clipboard = require('../../src/plugins/Clipboard'); +const store = require('../../src/store/index'); + +let apiManager; +let instance; +let exposedApiFunctions; + +describe('APIManager', () => { + beforeEach(() => { + instance = new Instance({}); + apiManager = new ApiManager(instance); + exposedApiFunctions = apiManager.getExposedApiFunctions(); + store(instance); + + new Clipboard(instance, { + CLIPBOARD_TITLE: 'TEST CLIPBOARD PLUGIN TITLE', + }); + }); + + describe('has exposed api', () => { + test('getRegisteredFunctions', () => { + const registeredFunctions = exposedApiFunctions.utils.getRegisteredFunctions(); + expect(Object.keys(registeredFunctions)).toEqual( + expect.arrayContaining([ + 'sendData', + 'getRegisteredFunctions', + 'addEventListener', + 'disconnect', + 'enableTrackEvents', + 'trackEvents', + ]), + ); + }); + + test('sendTrackEvent', () => { + let events = []; + + exposedApiFunctions.analytics.enableTrackEvents(true); + + // attach callback to get events + exposedApiFunctions.analytics.trackEvents((evts) => { + events = evts; + }); + + const button = document.getElementsByClassName('gm-clipboard-button')[0]; + expect(button).toBeTruthy(); + button.click(); + + // expect object to be exactly the same + expect(events).toEqual([{category: 'widget', action: 'open', name: 'Clipboard'}]); + }); + }); +});