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/DateUtils.js b/src/DateUtils.js index e8f92e34fa..0bce7c8a16 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -56,7 +56,7 @@ function twelveHourTime(date) { const minutes = pad(date.getMinutes()); const ampm = date.getHours() >= 12 ? 'PM' : 'AM'; hours = pad(hours ? hours : 12); - return `${hours}:${minutes} ${ampm}`; + return `${hours}:${minutes}${ampm}`; } module.exports = { @@ -83,7 +83,7 @@ module.exports = { return this.formatFullDate(date); }, - formatFullDate: function(date) { + formatFullDate: function(date, showTwelveHour=false) { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { @@ -91,7 +91,7 @@ module.exports = { monthName: months[date.getMonth()], day: date.getDate(), fullYear: date.getFullYear(), - time: this.formatTime(date), + time: showTwelveHour ? twelveHourTime(date) : this.formatTime(date), }); }, diff --git a/src/Lifecycle.js b/src/Lifecycle.js index b7fbaa0335..8bce6f8b48 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'; @@ -284,6 +285,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, @@ -421,6 +424,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/TextForEvent.js b/src/TextForEvent.js index 6438a9a1ad..fa78f9d61b 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -117,7 +117,10 @@ function textForTopicEvent(ev) { function textForRoomNameEvent(ev) { var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - + + if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { + return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName: senderDisplayName}); + } return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {senderDisplayName: senderDisplayName, roomName: ev.getContent().name}); } diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 56b9d56cc9..5abd758fa8 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -16,6 +16,7 @@ limitations under the License. import FileSaver from 'file-saver'; import React from 'react'; +import { _t } from '../../../languageHandler'; import * as Matrix from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; @@ -52,11 +53,11 @@ export default React.createClass({ const passphrase = this.refs.passphrase1.value; if (passphrase !== this.refs.passphrase2.value) { - this.setState({errStr: 'Passphrases must match'}); + this.setState({errStr: _t('Passphrases must match')}); return false; } if (!passphrase) { - this.setState({errStr: 'Passphrase must not be empty'}); + this.setState({errStr: _t('Passphrase must not be empty')}); return false; } @@ -109,24 +110,28 @@ export default React.createClass({ return (

- This process allows you to export the keys for messages - you have received in encrypted rooms to a local file. You - will then be able to import the file into another Matrix - client in the future, so that client will also be able to - decrypt these messages. + { _t( + 'This process allows you to export the keys for messages ' + + 'you have received in encrypted rooms to a local file. You ' + + 'will then be able to import the file into another Matrix ' + + 'client in the future, so that client will also be able to ' + + 'decrypt these messages.' + ) }

- The exported file will allow anyone who can read it to decrypt - any encrypted messages that you can see, so you should be - careful to keep it secure. To help with this, you should enter - a passphrase below, which will be used to encrypt the exported - data. It will only be possible to import the data by using the - same passphrase. + { _t( + 'The exported file will allow anyone who can read it to decrypt ' + + 'any encrypted messages that you can see, so you should be ' + + 'careful to keep it secure. To help with this, you should enter ' + + 'a passphrase below, which will be used to encrypt the exported ' + + 'data. It will only be possible to import the data by using the ' + + 'same passphrase.' + ) }

{this.state.errStr} @@ -135,7 +140,7 @@ export default React.createClass({
@@ -148,7 +153,7 @@ export default React.createClass({
diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index ddd13813e2..75b66e2969 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -19,6 +19,7 @@ import React from 'react'; import * as Matrix from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; function readFileAsArrayBuffer(file) { return new Promise((resolve, reject) => { @@ -112,20 +113,23 @@ export default React.createClass({ return (

- This process allows you to import encryption keys - that you had previously exported from another Matrix - client. You will then be able to decrypt any - messages that the other client could decrypt. + { _t( + 'This process allows you to import encryption keys ' + + 'that you had previously exported from another Matrix ' + + 'client. You will then be able to decrypt any ' + + 'messages that the other client could decrypt.' + ) }

- The export file will be protected with a passphrase. - You should enter the passphrase here, to decrypt the - file. + { _t( + 'The export file will be protected with a passphrase. ' + + 'You should enter the passphrase here, to decrypt the file.' + ) }

{this.state.errStr} @@ -134,7 +138,7 @@ export default React.createClass({
@@ -147,7 +151,7 @@ export default React.createClass({
diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 34d0110131..fef0544b8a 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -45,10 +45,10 @@ const COMMANDS = [ command: '/ddg', args: '', description: 'Searches DuckDuckGo for results', - } + }, ]; -let COMMAND_RE = /(^\/\w*)/g; +const COMMAND_RE = /(^\/\w*)/g; let instance = null; @@ -62,15 +62,15 @@ export default class CommandProvider extends AutocompleteProvider { async getCompletions(query: string, selection: {start: number, end: number}) { let completions = []; - let {command, range} = this.getCurrentCommand(query, selection); + const {command, range} = this.getCurrentCommand(query, selection); if (command) { - completions = this.fuse.search(command[0]).map(result => { + completions = this.fuse.search(command[0]).map((result) => { return { completion: result.command + ' ', component: (), range, }; @@ -84,8 +84,7 @@ export default class CommandProvider extends AutocompleteProvider { } static getInstance(): CommandProvider { - if (instance == null) - {instance = new CommandProvider();} + if (instance === null) instance = new CommandProvider(); return instance; } diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index b85e7dc2d2..75e53d4f77 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -95,7 +95,7 @@ var FilePanel = React.createClass({
; } else if (this.noRoom) { return
-
You must join the room to see its files
+
{_t("You must join the room to see its files")}
; } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index e5fab4dde7..ff864379fa 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"; @@ -192,6 +194,8 @@ module.exports = React.createClass({ RoomViewStore.addListener(this._onRoomViewStoreUpdated); this._onRoomViewStoreUpdated(); + if (!UserSettingsStore.getLocalSetting('analyticsOptOut', false)) Analytics.enable(); + // Used by _viewRoom before getting state from sync this.firstSyncComplete = false; this.firstSyncPromise = q.defer(); @@ -739,9 +743,9 @@ module.exports = React.createClass({ modal.close(); console.error("Failed to leave room " + roomId + " " + err); Modal.createDialog(ErrorDialog, { - title: "Failed to leave room", + title: _t("Failed to leave room"), description: (err && err.message ? err.message : - "Server may be unavailable, overloaded, or you hit a bug."), + _t("Server may be unavailable, overloaded, or you hit a bug.")), }); }); } @@ -1058,6 +1062,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/RoomView.js b/src/components/structures/RoomView.js index 6e853c135a..2189fa3856 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1414,49 +1414,48 @@ module.exports = React.createClass({ const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); if (!this.state.room) { - if (this.state.roomLoading) { - return ( -
- -
- ); + if (this.state.roomLoading) { + return ( +
+ +
+ ); + } else { + var inviterName = undefined; + if (this.props.oobData) { + inviterName = this.props.oobData.inviterName; + } + var invitedEmail = undefined; + if (this.props.thirdPartyInvite) { + invitedEmail = this.props.thirdPartyInvite.invitedEmail; } - else { - var inviterName = undefined; - if (this.props.oobData) { - inviterName = this.props.oobData.inviterName; - } - var invitedEmail = undefined; - if (this.props.thirdPartyInvite) { - invitedEmail = this.props.thirdPartyInvite.invitedEmail; - } - // We have no room object for this room, only the ID. - // We've got to this room by following a link, possibly a third party invite. - var room_alias = this.state.room_alias; - return ( -
- + +
+ -
- -
-
- ); - } +
+
+ ); + } } var myUserId = MatrixClientPeg.get().credentials.userId; @@ -1540,13 +1539,15 @@ module.exports = React.createClass({ } let aux = null; - if (this.state.forwardingEvent !== null) { - aux = ; - } else if (this.state.editingRoomSettings) { + let hideCancel = false; + if (this.state.editingRoomSettings) { aux = ; } else if (this.state.uploadingRoomSettings) { aux = ; + } else if (this.state.forwardingEvent !== null) { + aux = ; } else if (this.state.searching) { + hideCancel = true; // has own cancel aux = ; } else if (!myMember || myMember.membership !== "join") { // We do have a room object for this room, but we're not currently in it. @@ -1559,6 +1560,7 @@ module.exports = React.createClass({ if (this.props.thirdPartyInvite) { invitedEmail = this.props.thirdPartyInvite.invitedEmail; } + hideCancel = true; aux = ( + onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null} + onForgetClick={(myMember && myMember.membership === "leave") ? this.onForgetClick : null} + onLeaveClick={(myMember && myMember.membership === "join") ? this.onLeaveClick : null} + /> { auxPanel } { topUnreadMessagesBar } { messagePanel } diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index db428e72ce..efafd77009 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'; @@ -55,7 +56,7 @@ const gHVersionLabel = function(repo, token='') { // Enumerate some simple 'flip a bit' UI settings (if any). // 'id' gives the key name in the im.vector.web.settings account data event // 'label' is how we describe it in the UI. -// Warning: Each "label" string below must be added to i18n/strings/en_EN.json, +// Warning: Each "label" string below must be added to i18n/strings/en_EN.json, // since they will be translated when rendered. const SETTINGS_LABELS = [ { @@ -90,12 +91,25 @@ const SETTINGS_LABELS = [ */ ]; -// Warning: Each "label" string below must be added to i18n/strings/en_EN.json, +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 // { @@ -596,7 +610,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); + } + } />