diff --git a/src/Analytics.js b/src/Analytics.js new file mode 100644 index 0000000000..4f9ce6ad7d --- /dev/null +++ b/src/Analytics.js @@ -0,0 +1,145 @@ +/* + Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { getCurrentLanguage } from './languageHandler'; +import MatrixClientPeg from './MatrixClientPeg'; +import PlatformPeg from './PlatformPeg'; +import SdkConfig from './SdkConfig'; + +function redact(str) { + return str.replace(/#\/(room|user)\/(.+)/, "#/$1/"); +} + +const customVariables = { + 'App Platform': 1, + 'App Version': 2, + 'User Type': 3, + 'Chosen Language': 4, +}; + + +class Analytics { + constructor() { + this._paq = null; + this.disabled = true; + this.firstPage = true; + } + + /** + * Enable Analytics if initialized but disabled + * otherwise try and initalize, no-op if piwik config missing + */ + enable() { + if (this._paq || this._init()) { + this.disabled = false; + } + } + + /** + * Disable Analytics calls, will not fully unload Piwik until a refresh, + * but this is second best, Piwik should not pull anything implicitly. + */ + disable() { + this.disabled = true; + } + + _init() { + const config = SdkConfig.get(); + if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return; + + const url = config.piwik.url; + const siteId = config.piwik.siteId; + const self = this; + + window._paq = this._paq = window._paq || []; + + this._paq.push(['setTrackerUrl', url+'piwik.php']); + this._paq.push(['setSiteId', siteId]); + + this._paq.push(['trackAllContentImpressions']); + this._paq.push(['discardHashTag', false]); + this._paq.push(['enableHeartBeatTimer']); + this._paq.push(['enableLinkTracking', true]); + + const platform = PlatformPeg.get(); + this._setVisitVariable('App Platform', platform.getHumanReadableName()); + platform.getAppVersion().then((version) => { + this._setVisitVariable('App Version', version); + }).catch(() => { + this._setVisitVariable('App Version', 'unknown'); + }); + + this._setVisitVariable('Chosen Language', getCurrentLanguage()); + + (function() { + const g = document.createElement('script'); + const s = document.getElementsByTagName('script')[0]; + g.type='text/javascript'; g.async=true; g.defer=true; g.src=url+'piwik.js'; + + g.onload = function() { + console.log('Initialised anonymous analytics'); + self._paq = window._paq; + }; + + s.parentNode.insertBefore(g, s); + })(); + + return true; + } + + trackPageChange() { + if (this.disabled) return; + if (this.firstPage) { + // De-duplicate first page + // router seems to hit the fn twice + this.firstPage = false; + return; + } + this._paq.push(['setCustomUrl', redact(window.location.href)]); + this._paq.push(['trackPageView']); + } + + trackEvent(category, action, name) { + if (this.disabled) return; + this._paq.push(['trackEvent', category, action, name]); + } + + logout() { + if (this.disabled) return; + this._paq.push(['deleteCookies']); + } + + login() { // not used currently + const cli = MatrixClientPeg.get(); + if (this.disabled || !cli) return; + + this._paq.push(['setUserId', `@${cli.getUserIdLocalpart()}:${cli.getDomain()}`]); + } + + _setVisitVariable(key, value) { + this._paq.push(['setCustomVariable', customVariables[key], key, value, 'visit']); + } + + setGuest(guest) { + if (this.disabled) return; + this._setVisitVariable('User Type', guest ? 'Guest' : 'Logged In'); + } +} + +if (!global.mxAnalytics) { + global.mxAnalytics = new Analytics(); +} +module.exports = global.mxAnalytics; diff --git a/src/BasePlatform.js b/src/BasePlatform.js index 6eed22f436..7e5242b1fd 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -29,6 +29,11 @@ export default class BasePlatform { this.errorDidOccur = false; } + // Used primarily for Analytics + getHumanReadableName(): string { + return 'Base Platform'; + } + setNotificationCount(count: number) { this.notificationCount = count; } diff --git a/src/Lifecycle.js b/src/Lifecycle.js index a4a4f557bc..0bf4c575e5 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -19,6 +19,7 @@ import q from 'q'; import Matrix from 'matrix-js-sdk'; import MatrixClientPeg from './MatrixClientPeg'; +import Analytics from './Analytics'; import Notifier from './Notifier'; import UserActivity from './UserActivity'; import Presence from './Presence'; @@ -276,6 +277,8 @@ export function initRtsClient(url) { export function setLoggedIn(credentials) { credentials.guest = Boolean(credentials.guest); + Analytics.setGuest(credentials.guest); + console.log( "setLoggedIn: mxid:", credentials.userId, "deviceId:", credentials.deviceId, @@ -403,6 +406,7 @@ export function onLoggedOut() { } function _clearLocalStorage() { + Analytics.logout(); if (!window.localStorage) { return; } diff --git a/src/Modal.js b/src/Modal.js index 7be37da92e..8d53b2da7d 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -19,6 +19,7 @@ limitations under the License. var React = require('react'); var ReactDOM = require('react-dom'); +import Analytics from './Analytics'; import sdk from './index'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; @@ -104,6 +105,9 @@ class ModalManager { } createDialog(Element, props, className) { + if (props && props.title) { + Analytics.trackEvent('Modal', props.title, 'createDialog'); + } return this.createDialogAsync((cb) => {cb(Element);}, props, className); } diff --git a/src/Notifier.js b/src/Notifier.js index eeedbcf365..e89947e958 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -18,6 +18,7 @@ limitations under the License. import MatrixClientPeg from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; import TextForEvent from './TextForEvent'; +import Analytics from './Analytics'; import Avatar from './Avatar'; import dis from './dispatcher'; import sdk from './index'; @@ -121,6 +122,9 @@ const Notifier = { setEnabled: function(enable, callback) { const plaf = PlatformPeg.get(); if (!plaf) return; + + Analytics.trackEvent('Notifier', 'Set Enabled', enable); + // make sure that we persist the current setting audio_enabled setting // before changing anything if (global.localStorage) { @@ -199,6 +203,8 @@ const Notifier = { setToolbarHidden: function(hidden, persistent = true) { this.toolbarHidden = hidden; + Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden); + // XXX: why are we dispatching this here? // this is nothing to do with notifier_enabled dis.dispatch({ diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 542869d0d1..1da4b9d988 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -20,6 +20,8 @@ import q from 'q'; import React from 'react'; import Matrix from "matrix-js-sdk"; +import Analytics from "../../Analytics"; +import UserSettingsStore from '../../UserSettingsStore'; import MatrixClientPeg from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; @@ -189,6 +191,8 @@ module.exports = React.createClass({ componentWillMount: function() { SdkConfig.put(this.props.config); + if (!UserSettingsStore.getLocalSetting('analyticsOptOut', false)) Analytics.enable(); + // Used by _viewRoom before getting state from sync this.firstSyncComplete = false; this.firstSyncPromise = q.defer(); @@ -1002,6 +1006,7 @@ module.exports = React.createClass({ if (this.props.onNewScreen) { this.props.onNewScreen(screen); } + Analytics.trackPageChange(); }, onAliasClick: function(event, alias) { diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 6b63ad9ebd..725139de64 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -28,6 +28,7 @@ const GeminiScrollbar = require('react-gemini-scrollbar'); const Email = require('../../email'); const AddThreepid = require('../../AddThreepid'); const SdkConfig = require('../../SdkConfig'); +import Analytics from '../../Analytics'; import AccessibleButton from '../views/elements/AccessibleButton'; import { _t } from '../../languageHandler'; import * as languageHandler from '../../languageHandler'; @@ -90,12 +91,25 @@ const SETTINGS_LABELS = [ */ ]; +const ANALYTICS_SETTINGS_LABELS = [ + { + id: 'analyticsOptOut', + label: 'Opt out of analytics', + fn: function(checked) { + Analytics[checked ? 'disable' : 'enable'](); + }, + }, +]; + // Warning: Each "label" string below must be added to i18n/strings/en_EN.json, // since they will be translated when rendered. const CRYPTO_SETTINGS_LABELS = [ { id: 'blacklistUnverifiedDevices', label: 'Never send encrypted messages to unverified devices from this device', + fn: function(checked) { + MatrixClientPeg.get().setGlobalBlacklistUnverifiedDevices(checked); + }, }, // XXX: this is here for documentation; the actual setting is managed via RoomSettings // { @@ -599,7 +613,12 @@ module.exports = React.createClass({ UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) } + onChange={ + (e) => { + UserSettingsStore.setSyncedSetting(setting.id, e.target.checked); + if (setting.fn) setting.fn(e.target.checked); + } + } />