From 5219b6be80d1ae8032b0fbc986d9bb487ef6a9d6 Mon Sep 17 00:00:00 2001 From: James Salter Date: Mon, 6 Dec 2021 09:39:33 +1100 Subject: [PATCH] Analytics opt in for posthog (#6936) * Add a new flag pseudonymousAnalyticsOptIn replacing analyticsOptIn, stored at account level, so people only need to opt in once. * Show a toast in login to users that have analyticsOptIn set but not yet pseudonymousAnalyticsOptIn prompting them confirm the new method is okay. Update the copy of the existing opt-in toast. Don't notify users that previously opted out. * Update the copy in settings * Add a new learn more dialog * Support a new config flag analyticsOwner which is used in these toasts when explaining which entity the data is sent to ("Help improve %(analyticsOwner)"). If unset, display brand. This allows deployments whose brand differs from the receiver of the analytics to explain the situation to their users (e.g. AcmeCorp badges their app, but explains the data is sent to Element, not them) * The new opt-in and flags are only used when posthog is configured; prior to that there are no changes to UX or tracking behaviour. --- res/css/_components.scss | 1 + res/css/views/dialogs/_Analytics.scss | 4 + .../dialogs/_AnalyticsLearnMoreDialog.scss | 64 ++++++++ res/css/views/toasts/_AnalyticsToast.scss | 12 +- res/img/element-shiny.svg | 15 ++ res/img/tick-circle.svg | 4 + src/Analytics.tsx | 14 +- src/Lifecycle.ts | 7 +- src/PosthogAnalytics.ts | 63 ++++---- src/components/structures/MatrixChat.tsx | 69 ++++++--- .../dialogs/AnalyticsLearnMoreDialog.tsx | 109 ++++++++++++++ .../tabs/user/SecurityUserSettingsTab.tsx | 48 +++--- src/dispatcher/actions.ts | 25 ++++ src/i18n/strings/en_EN.json | 25 ++-- src/settings/Settings.tsx | 14 +- .../PseudonymousAnalyticsController.ts | 26 ---- .../handlers/AccountSettingsHandler.ts | 18 ++- src/toasts/AnalyticsToast.tsx | 140 ++++++++++++++---- test/end-to-end-tests/src/scenarios/toast.js | 4 +- 19 files changed, 512 insertions(+), 150 deletions(-) create mode 100644 res/css/views/dialogs/_AnalyticsLearnMoreDialog.scss create mode 100644 res/img/element-shiny.svg create mode 100644 res/img/tick-circle.svg create mode 100644 src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx delete mode 100644 src/settings/controllers/PseudonymousAnalyticsController.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index adfd98925a..3eb8b9dadf 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -69,6 +69,7 @@ @import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; +@import "./views/dialogs/_AnalyticsLearnMoreDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; diff --git a/res/css/views/dialogs/_Analytics.scss b/res/css/views/dialogs/_Analytics.scss index e403d3b207..b0be14d684 100644 --- a/res/css/views/dialogs/_Analytics.scss +++ b/res/css/views/dialogs/_Analytics.scss @@ -16,4 +16,8 @@ limitations under the License. .mx_AnalyticsModal table { margin: 10px 0px; + + .mx_AnalyticsModal_label { + width: 400px; + } } diff --git a/res/css/views/dialogs/_AnalyticsLearnMoreDialog.scss b/res/css/views/dialogs/_AnalyticsLearnMoreDialog.scss new file mode 100644 index 0000000000..7a3edd5f7d --- /dev/null +++ b/res/css/views/dialogs/_AnalyticsLearnMoreDialog.scss @@ -0,0 +1,64 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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. +*/ + +.mx_AnalyticsLearnMoreDialog { + max-width: 500px; + .mx_AnalyticsLearnMore_image_holder { + background-image: url('$(res)/img/element-shiny.svg'); + background-repeat: no-repeat; + background-position: center top; + height: 112px; + padding: 20px 0px; + } + + .mx_Dialog_content { + margin-bottom: 0px; + } + + .mx_AnalyticsLearnMore_copy { + border-bottom: 1px solid $menu-border-color; + padding-bottom: 20px; + margin-bottom: 20px; + } + + a { + color: $accent; + text-decoration: none; + } + + .mx_AnalyticsPolicyLink { + display: inline-block; + mask-image: url('$(res)/img/external-link.svg'); + background-color: $accent; + mask-repeat: no-repeat; + mask-size: contain; + width: 12px; + height: 12px; + margin-left: 3px; + vertical-align: middle; + } + + .mx_AnalyticsLearnMore_bullets { + padding-left: 0px; + } + + .mx_AnalyticsLearnMore_bullets li { + background: url('$(res)/img/tick-circle.svg') no-repeat; + list-style-type: none; + padding: 2px 0px 20px 32px; + vertical-align: middle; + } +} diff --git a/res/css/views/toasts/_AnalyticsToast.scss b/res/css/views/toasts/_AnalyticsToast.scss index 15a94420fa..80e95535a5 100644 --- a/res/css/views/toasts/_AnalyticsToast.scss +++ b/res/css/views/toasts/_AnalyticsToast.scss @@ -15,13 +15,17 @@ limitations under the License. */ .mx_AnalyticsToast { - .mx_AccessibleButton_kind_danger { - background: none; - color: $accent; + .mx_AccessibleButton_kind_danger_outline { + background-color: $accent; + color: #ffffff; + border: 1px solid $accent; + font-weight: $font-semi-bold; } .mx_AccessibleButton_kind_primary { - background: $accent; + background-color: $accent; color: #ffffff; + border: 1px solid $accent; + font-weight: $font-semi-bold; } } diff --git a/res/img/element-shiny.svg b/res/img/element-shiny.svg new file mode 100644 index 0000000000..006713bc0f --- /dev/null +++ b/res/img/element-shiny.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/res/img/tick-circle.svg b/res/img/tick-circle.svg new file mode 100644 index 0000000000..7cedb62985 --- /dev/null +++ b/res/img/tick-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Analytics.tsx b/src/Analytics.tsx index 3ff7b2a767..10d3ce9538 100644 --- a/src/Analytics.tsx +++ b/src/Analytics.tsx @@ -393,16 +393,26 @@ export class Analytics { ]; // FIXME: Using an import will result in test failures + const cookiePolicyUrl = SdkConfig.get().piwik?.policyUrl; const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + const cookiePolicyLink = _t( + "Our complete cookie policy can be found here.", + {}, + { + "CookiePolicyLink": (sub) => { + return { sub }; + }, + }); Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, { title: _t('Analytics'), description:
-
{ _t('The information being sent to us to help make %(brand)s better includes:', { + { cookiePolicyUrl &&

{ cookiePolicyLink }

} +
{ _t('Some examples of the information being sent to us to help make %(brand)s better includes:', { brand: SdkConfig.get().brand, }) }
{ rows.map((row) => -
{ _t( + { _t( customVariables[row[0]].expl, customVariables[row[0]].getTextVariables ? customVariables[row[0]].getTextVariables() : diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index ef7b7b33de..9fd32d51e5 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -585,12 +585,13 @@ async function doSetLoggedIn( MatrixClientPeg.replaceUsingCreds(credentials); - PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId); - setSentryUser(credentials.userId); - const client = MatrixClientPeg.get(); + if (PosthogAnalytics.instance.isEnabled()) { + PosthogAnalytics.instance.startListeningToSettingsChanges(); + } + const client = MatrixClientPeg.get(); if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) { // If we just logged in, try to rehydrate a device instead of using a // new device. If it succeeds, we'll get a new device ID, so make sure diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index f071a56241..5619d3f0b2 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -17,11 +17,11 @@ limitations under the License. import posthog, { PostHog } from 'posthog-js'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; -import SettingsStore from './settings/SettingsStore'; import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; +import SettingsStore from "./settings/SettingsStore"; /* Posthog analytics tracking. * @@ -132,10 +132,10 @@ export class PosthogAnalytics { private anonymity = Anonymity.Disabled; // set true during the constructor if posthog config is present, otherwise false - private enabled = false; + private readonly enabled: boolean = false; private static _instance = null; private platformSuperProperties = {}; - private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id"; + private static ANALYTICS_EVENT_TYPE = "im.vector.analytics"; public static get instance(): PosthogAnalytics { if (!this._instance) { @@ -197,29 +197,6 @@ export class PosthogAnalytics { return properties; }; - private static getAnonymityFromSettings(): Anonymity { - // determine the current anonymity level based on current user settings - - // "Send anonymous usage data which helps us improve Element. This will use a cookie." - const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true); - - // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." - // - // TODO: Currently, this is only a labs flag, for testing purposes. - const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true); - - let anonymity; - if (pseudonumousOptIn) { - anonymity = Anonymity.Pseudonymous; - } else if (analyticsOptIn) { - anonymity = Anonymity.Anonymous; - } else { - anonymity = Anonymity.Disabled; - } - - return anonymity; - } - private registerSuperProperties(properties: posthog.Properties) { if (this.enabled) { this.posthog.register(properties); @@ -279,7 +256,7 @@ export class PosthogAnalytics { // Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows // different devices to send the same ID. try { - const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_ID_EVENT_TYPE); + const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_EVENT_TYPE); let analyticsID = accountData?.id; if (!analyticsID) { // Couldn't retrieve an analytics ID from user settings, so create one and set it on the server. @@ -288,7 +265,8 @@ export class PosthogAnalytics { // until the next time account data is refreshed and this function is called (most likely on next // page load). This will happen pretty infrequently, so we can tolerate the possibility. analyticsID = analyticsIdGenerator(); - await client.setAccountData("im.vector.web.analytics_id", { id: analyticsID }); + await client.setAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE, + Object.assign({ id: analyticsID }, accountData)); } this.posthog.identify(analyticsID); } catch (e) { @@ -307,7 +285,7 @@ export class PosthogAnalytics { if (this.enabled) { this.posthog.reset(); } - this.setAnonymity(Anonymity.Anonymous); + this.setAnonymity(Anonymity.Disabled); } public async trackPseudonymousEvent( @@ -351,12 +329,31 @@ export class PosthogAnalytics { this.registerSuperProperties(this.platformSuperProperties); } - public async updateAnonymityFromSettings(userId?: string): Promise { + public async updateAnonymityFromSettings(pseudonymousOptIn: boolean): Promise { // Update this.anonymity based on the user's analytics opt-in settings - // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous - this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); - if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { + const anonymity = pseudonymousOptIn ? Anonymity.Pseudonymous : Anonymity.Disabled; + this.setAnonymity(anonymity); + if (anonymity === Anonymity.Pseudonymous) { await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId); } + + if (anonymity !== Anonymity.Disabled) { + await PosthogAnalytics.instance.updatePlatformSuperProperties(); + } + } + + public startListeningToSettingsChanges(): void { + // Listen to account data changes from sync so we can observe changes to relevant flags and update. + // This is called - + // * On page load, when the account data is first received by sync + // * On login + // * When another device changes account data + // * When the user changes their preferences on this device + // Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings + // won't be called (i.e. this.anonymity will be left as the default, until the setting changes) + SettingsStore.watchSetting("pseudonymousAnalyticsOptIn", null, + (originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => { + this.updateAnonymityFromSettings(!!newValue); + }); } } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 28e801daf0..98d2203321 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -18,7 +18,7 @@ import React, { ComponentType, createRef } from 'react'; import { createClient } from "matrix-js-sdk/src/matrix"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils"; +import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils"; // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss import 'focus-visible'; @@ -59,8 +59,9 @@ import * as StorageManager from "../../utils/StorageManager"; import type LoggedInViewType from "./LoggedInView"; import { Action } from "../../dispatcher/actions"; import { - showToast as showAnalyticsToast, hideToast as hideAnalyticsToast, + showAnonymousAnalyticsOptInToast, + showPseudonymousAnalyticsOptInToast, } from "../../toasts/AnalyticsToast"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; @@ -382,13 +383,10 @@ export default class MatrixChat extends React.PureComponent { }); } - if (SettingsStore.getValue("analyticsOptIn")) { + if (SettingsStore.getValue("pseudonymousAnalyticsOptIn")) { Analytics.enable(); } - PosthogAnalytics.instance.updateAnonymityFromSettings(); - PosthogAnalytics.instance.updatePlatformSuperProperties(); - CountlyAnalytics.instance.enable(/* anonymous = */ true); initSentry(SdkConfig.get()["sentry"]); @@ -500,8 +498,6 @@ export default class MatrixChat extends React.PureComponent { } else { dis.dispatch({ action: "view_welcome_page" }); } - } else if (SettingsStore.getValue("analyticsOptIn")) { - CountlyAnalytics.instance.enable(/* anonymous = */ false); } }); // Note we don't catch errors from this: we catch everything within @@ -816,10 +812,10 @@ export default class MatrixChat extends React.PureComponent { hideToSRUsers: false, }); break; - case 'accept_cookies': + case Action.AnonymousAnalyticsAccept: + hideAnalyticsToast(); SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true); SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false); - hideAnalyticsToast(); if (Analytics.canEnable()) { Analytics.enable(); } @@ -827,10 +823,18 @@ export default class MatrixChat extends React.PureComponent { CountlyAnalytics.instance.enable(/* anonymous = */ false); } break; - case 'reject_cookies': + case Action.AnonymousAnalyticsReject: + hideAnalyticsToast(); SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false); + break; + case Action.PseudonymousAnalyticsAccept: hideAnalyticsToast(); + SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, true); + break; + case Action.PseudonymousAnalyticsReject: + hideAnalyticsToast(); + SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, false); break; } }; @@ -1323,13 +1327,16 @@ export default class MatrixChat extends React.PureComponent { StorageManager.tryPersistStorage(); - // defer the following actions by 30 seconds to not throw them at the user immediately - await sleep(30); - if (SettingsStore.getValue("showCookieBar") && - (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) - ) { - showAnalyticsToast(this.props.config.piwik?.policyUrl); + if (PosthogAnalytics.instance.isEnabled()) { + this.initPosthogAnalyticsToast(); + } else if (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) { + if (SettingsStore.getValue("showCookieBar") && + (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) + ) { + showAnonymousAnalyticsOptInToast(); + } } + if (SdkConfig.get().mobileGuideToast) { // The toast contains further logic to detect mobile platforms, // check if it has been dismissed before, etc. @@ -1337,6 +1344,34 @@ export default class MatrixChat extends React.PureComponent { } } + private showPosthogToast(analyticsOptIn: boolean) { + showPseudonymousAnalyticsOptInToast(analyticsOptIn); + } + + private initPosthogAnalyticsToast() { + // Show the analytics toast if necessary + if (SettingsStore.getValue("pseudonymousAnalyticsOptIn") === null) { + this.showPosthogToast(SettingsStore.getValue("analyticsOptIn", null, true)); + } + + // Listen to changes in settings and show the toast if appropriate - this is necessary because account + // settings can still be changing at this point in app init (due to the initial sync being cached, then + // subsequent syncs being received from the server) + SettingsStore.watchSetting("pseudonymousAnalyticsOptIn", null, + (originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => { + if (newValue === null) { + this.showPosthogToast(SettingsStore.getValue("analyticsOptIn", null, true)); + } else { + // It's possible for the value to change if a cached sync loads at page load, but then network + // sync contains a new value of the flag with it set to false (e.g. another device set it since last + // loading the page); so hide the toast. + // (this flipping usually happens before first render so the user won't notice it; anyway flicker + // on/off is probably better than showing the toast again when the user already dismissed it) + hideAnalyticsToast(); + } + }); + } + private showScreenAfterLogin() { // If screenAfterLogin is set, use that, then null it so that a second login will // result in view_home_page, _user_settings or _room_directory diff --git a/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx new file mode 100644 index 0000000000..6c60c46175 --- /dev/null +++ b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx @@ -0,0 +1,109 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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 BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import DialogButtons from "../elements/DialogButtons"; +import React from "react"; +import Modal from "../../../Modal"; +import SdkConfig from "../../../SdkConfig"; + +export enum ButtonClicked { + Primary, + Cancel, +} + +interface IProps { + onFinished?(buttonClicked?: ButtonClicked): void; + analyticsOwner: string; + privacyPolicyUrl?: string; + primaryButton?: string; + cancelButton?: string; + hasCancel?: boolean; +} + +const AnalyticsLearnMoreDialog: React.FC = ({ + onFinished, + analyticsOwner, + privacyPolicyUrl, + primaryButton, + cancelButton, + hasCancel, +}) => { + const onPrimaryButtonClick = () => onFinished && onFinished(ButtonClicked.Primary); + const onCancelButtonClick = () => onFinished && onFinished(ButtonClicked.Cancel); + const privacyPolicyLink = privacyPolicyUrl ? + + { + _t("You can read all our terms here", {}, { + "PrivacyPolicyUrl": (sub) => { + return + { sub } + + ; + }, + }) + } + : ""; + return +
+
+
+ { _t("Help us identify issues and improve Element by sharing anonymous usage data. " + + "To understand how people use multiple devices, we'll generate a random identifier, " + + "shared by your devices.", + ) } +
+
    +
  • { _t("We don't record or profile any account data", + {}, { "Bold": (sub) => { sub } }) }
  • +
  • { _t("We don't share information with third parties", + {}, { "Bold": (sub) => { sub } }) }
  • +
  • { _t("You can turn this off anytime in settings") }
  • +
+ { privacyPolicyLink } +
+ + ; +}; + +export const showDialog = (props: Omit): void => { + const privacyPolicyUrl = SdkConfig.get().piwik?.policyUrl; + const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand; + Modal.createTrackedDialog( + "Analytics Learn More", + "", + AnalyticsLearnMoreDialog, + { privacyPolicyUrl, analyticsOwner, ...props }, + "mx_AnalyticsLearnMoreDialog_wrapper", + ); +}; + +export default AnalyticsLearnMoreDialog; diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index ed560b8929..21977b36dc 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -19,7 +19,6 @@ import React from 'react'; import { sleep } from "matrix-js-sdk/src/utils"; import { _t } from "../../../../../languageHandler"; -import SdkConfig from "../../../../../SdkConfig"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; import AccessibleButton from "../../../elements/AccessibleButton"; import Analytics from "../../../../../Analytics"; @@ -32,7 +31,6 @@ import { UIFeature } from "../../../../../settings/UIFeature"; import E2eAdvancedPanel, { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import CountlyAnalytics from "../../../../../CountlyAnalytics"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; -import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; import { ActionPayload } from "../../../../../dispatcher/payloads"; import { Room } from "matrix-js-sdk/src/models/room"; import CryptographyPanel from "../../CryptographyPanel"; @@ -41,8 +39,10 @@ import SettingsFlag from "../../../elements/SettingsFlag"; import CrossSigningPanel from "../../CrossSigningPanel"; import EventIndexPanel from "../../EventIndexPanel"; import InlineSpinner from "../../../elements/InlineSpinner"; +import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; import { logger } from "matrix-js-sdk/src/logger"; +import { showDialog as showAnalyticsLearnMoreDialog } from "../../../dialogs/AnalyticsLearnMoreDialog"; interface IIgnoredUserProps { userId: string; @@ -118,7 +118,6 @@ export default class SecurityUserSettingsTab extends React.Component { checked ? Analytics.enable() : Analytics.disable(); CountlyAnalytics.instance.enable(/* anonymous = */ !checked); - PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); }; private onMyMembership = (room: Room, membership: string): void => { @@ -272,8 +271,6 @@ export default class SecurityUserSettingsTab extends React.Component { _t("Secure Backup") } @@ -312,24 +309,41 @@ export default class SecurityUserSettingsTab extends React.Component { + if (PosthogAnalytics.instance.isEnabled()) { + showAnalyticsLearnMoreDialog({ + primaryButton: _t("Okay"), + hasCancel: false, + }); + } else { + Analytics.showDetailsModal(); + } + }; privacySection =
{ _t("Privacy") }
{ _t("Analytics") }
- { _t( - "%(brand)s collects anonymous analytics to allow us to improve the application.", - { brand }, - ) } -   - { _t("Privacy is important to us, so we don't collect any personal or " + - "identifiable data for our analytics.") } - - { _t("Learn more about how we use analytics.") } - +

+ { _t("Share anonymous data to help us identify issues. Nothing personal. " + + "No third parties.") } +

+

+ + { _t("Learn more") } + +

- + { + PosthogAnalytics.instance.isEnabled() ? + : + + }
; } diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 6291e86a70..4e49c1e61b 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -203,4 +203,29 @@ export enum Action { * Fires when a user starts to edit event (e.g. up arrow in compositor) */ EditEvent = "edit_event", + + /** + * The user accepted pseudonymous analytics (i.e. posthog) from the toast + * Payload: none + */ + PseudonymousAnalyticsAccept = "pseudonymous_analytics_accept", + + /** + * The user rejected pseudonymous analytics (i.e. posthog) from the toast + * Payload: none + */ + PseudonymousAnalyticsReject = "pseudonymous_analytics_reject", + + /** + * The user accepted anonymous analytics (i.e. matomo, pre-posthog) from the toast + * (this action and its handler can be removed once posthog is rolled out) + * Payload: none + */ + AnonymousAnalyticsAccept = "anonymous_analytics_accept", + + /** + * The user rejected anonymous analytics (i.e. matomo, pre-posthog) from the toast + * Payload: none + */ + AnonymousAnalyticsReject = "anonymous_analytics_reject" } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0212331015..1154d242ad 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -28,8 +28,9 @@ "e.g. ": "e.g. ", "Your user agent": "Your user agent", "Your device resolution": "Your device resolution", + "Our complete cookie policy can be found here.": "Our complete cookie policy can be found here.", "Analytics": "Analytics", - "The information being sent to us to help make %(brand)s better includes:": "The information being sent to us to help make %(brand)s better includes:", + "Some examples of the information being sent to us to help make %(brand)s better includes:": "Some examples of the information being sent to us to help make %(brand)s better includes:", "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.", "Error": "Error", "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", @@ -748,8 +749,14 @@ "Topic: %(topic)s": "Topic: %(topic)s", "Error fetching file": "Error fetching file", "File Attached": "File Attached", - "Help us improve %(brand)s": "Help us improve %(brand)s", + "Enable": "Enable", + "That's fine": "That's fine", + "Stop": "Stop", "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.", + "Help improve %(analyticsOwner)s": "Help improve %(analyticsOwner)s", + "You previously consented to share anonymous usage data with us. We're updating how that works.": "You previously consented to share anonymous usage data with us. We're updating how that works.", + "Learn more": "Learn more", + "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More": "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More", "Yes": "Yes", "No": "No", "You have unverified logins": "You have unverified logins", @@ -759,7 +766,6 @@ "Don't miss a reply": "Don't miss a reply", "Notifications": "Notifications", "Enable desktop notifications": "Enable desktop notifications", - "Enable": "Enable", "Unknown caller": "Unknown caller", "Voice call": "Voice call", "Video call": "Video call", @@ -845,7 +851,6 @@ "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", - "Send pseudonymous analytics data": "Send pseudonymous analytics data", "Polls (under active development)": "Polls (under active development)", "Show info about bridges in room settings": "Show info about bridges in room settings", "New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)", @@ -1447,10 +1452,9 @@ "Message search": "Message search", "Cross-signing": "Cross-signing", "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.", + "Okay": "Okay", "Privacy": "Privacy", - "%(brand)s collects anonymous analytics to allow us to improve the application.": "%(brand)s collects anonymous analytics to allow us to improve the application.", - "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.": "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.", - "Learn more about how we use analytics.": "Learn more about how we use analytics.", + "Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Share anonymous data to help us identify issues. Nothing personal. No third parties.", "Where you're signed in": "Where you're signed in", "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.", "Sidebar": "Sidebar", @@ -2287,6 +2291,11 @@ "Try using one of the following valid address types: %(validTypesList)s.": "Try using one of the following valid address types: %(validTypesList)s.", "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.", "Use an identity server to invite by email. Manage in Settings.": "Use an identity server to invite by email. Manage in Settings.", + "You can read all our terms here": "You can read all our terms here", + "Help us identify issues and improve Element by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.": "Help us identify issues and improve Element by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.", + "We don't record or profile any account data": "We don't record or profile any account data", + "We don't share information with third parties": "We don't share information with third parties", + "You can turn this off anytime in settings": "You can turn this off anytime in settings", "The following users may not exist": "The following users may not exist", "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?", "Invite anyway and never warn me again": "Invite anyway and never warn me again", @@ -2455,7 +2464,6 @@ "The export was cancelled successfully": "The export was cancelled successfully", "Your export was successful. Find it in your Downloads folder.": "Your export was successful. Find it in your Downloads folder.", "Are you sure you want to stop exporting your data? If you do, you'll need to start over.": "Are you sure you want to stop exporting your data? If you do, you'll need to start over.", - "Stop": "Stop", "Exporting your data": "Exporting your data", "Export Chat": "Export Chat", "Select from the options below to export chats from your timeline": "Select from the options below to export chats from your timeline", @@ -2656,7 +2664,6 @@ "We call the places where you can host your account 'homeservers'.": "We call the places where you can host your account 'homeservers'.", "Other homeserver": "Other homeserver", "Use your preferred Matrix homeserver if you have one, or host your own.": "Use your preferred Matrix homeserver if you have one, or host your own.", - "Learn more": "Learn more", "About homeservers": "About homeservers", "Reset event store?": "Reset event store?", "You most likely do not want to reset your event index store": "You most likely do not want to reset your event index store", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 346af1c766..61327a1df0 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -40,7 +40,6 @@ import { OrderedMultiController } from "./controllers/OrderedMultiController"; import { Layout } from "./enums/Layout"; import ReducedMotionController from './controllers/ReducedMotionController'; import IncompatibleController from "./controllers/IncompatibleController"; -import PseudonymousAnalyticsController from './controllers/PseudonymousAnalyticsController'; import NewLayoutSwitcherController from './controllers/NewLayoutSwitcherController'; import { ImageSize } from "./enums/ImageSize"; import { MetaSpace } from "../stores/spaces"; @@ -301,14 +300,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_pseudonymous_analytics_opt_in": { - isFeature: true, - labsGroup: LabGroup.Analytics, - supportedLevels: LEVELS_FEATURE, - displayName: _td('Send pseudonymous analytics data'), - default: false, - controller: new PseudonymousAnalyticsController(), - }, "feature_polls": { isFeature: true, labsGroup: LabGroup.Messaging, @@ -621,6 +612,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, default: true, }, + "pseudonymousAnalyticsOptIn": { + supportedLevels: [SettingLevel.ACCOUNT], + displayName: _td('Send analytics data'), + default: null, + }, "autocompleteDelay": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, default: 200, diff --git a/src/settings/controllers/PseudonymousAnalyticsController.ts b/src/settings/controllers/PseudonymousAnalyticsController.ts deleted file mode 100644 index a82b9685ef..0000000000 --- a/src/settings/controllers/PseudonymousAnalyticsController.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* -Copyright 2021 The Matrix.org Foundation C.I.C. - -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 SettingController from "./SettingController"; -import { SettingLevel } from "../SettingLevel"; -import { PosthogAnalytics } from "../../PosthogAnalytics"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; - -export default class PseudonymousAnalyticsController extends SettingController { - public onChange(level: SettingLevel, roomId: string, newValue: any) { - PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); - } -} diff --git a/src/settings/handlers/AccountSettingsHandler.ts b/src/settings/handlers/AccountSettingsHandler.ts index 9ae3176fb8..dca7102535 100644 --- a/src/settings/handlers/AccountSettingsHandler.ts +++ b/src/settings/handlers/AccountSettingsHandler.ts @@ -28,6 +28,7 @@ const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs"; const BREADCRUMBS_EVENT_TYPES = [BREADCRUMBS_LEGACY_EVENT_TYPE, BREADCRUMBS_EVENT_TYPE]; const RECENT_EMOJI_EVENT_TYPE = "io.element.recent_emoji"; const INTEG_PROVISIONING_EVENT_TYPE = "im.vector.setting.integration_provisioning"; +const ANALYTICS_EVENT_TYPE = "im.vector.analytics"; /** * Gets and sets settings at the "account" level for the current user. @@ -56,7 +57,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa } this.watchers.notifyUpdate("urlPreviewsEnabled", null, SettingLevel.ACCOUNT, val); - } else if (event.getType() === "im.vector.web.settings") { + } else if (event.getType() === "im.vector.web.settings" || event.getType() === ANALYTICS_EVENT_TYPE) { // Figure out what changed and fire those updates const prevContent = prevEvent ? prevEvent.getContent() : {}; const changedSettings = objectKeyChanges>(prevContent, event.getContent()); @@ -127,6 +128,13 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa return value; } + if (settingName === "pseudonymousAnalyticsOptIn") { + const content = this.getSettings(ANALYTICS_EVENT_TYPE) || {}; + // Check to make sure that we actually got a boolean + if (typeof(content[settingName]) !== "boolean") return null; + return content[settingName]; + } + const settings = this.getSettings() || {}; let preferredValue = settings[settingName]; @@ -179,6 +187,14 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa return; } + // Special case analytics + if (settingName === "pseudonymousAnalyticsOptIn") { + const content = this.getSettings(ANALYTICS_EVENT_TYPE) || {}; + content[settingName] = newValue; + await MatrixClientPeg.get().setAccountData(ANALYTICS_EVENT_TYPE, content); + return; + } + const content = this.getSettings() || {}; content[settingName] = newValue; await MatrixClientPeg.get().setAccountData("im.vector.web.settings", content); diff --git a/src/toasts/AnalyticsToast.tsx b/src/toasts/AnalyticsToast.tsx index 5a7737b1a6..1072ae2907 100644 --- a/src/toasts/AnalyticsToast.tsx +++ b/src/toasts/AnalyticsToast.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ReactNode } from "react"; import { _t } from "../languageHandler"; import SdkConfig from "../SdkConfig"; @@ -23,16 +23,52 @@ import Analytics from "../Analytics"; import AccessibleButton from "../components/views/elements/AccessibleButton"; import GenericToast from "../components/views/toasts/GenericToast"; import ToastStore from "../stores/ToastStore"; +import { + ButtonClicked, + showDialog as showAnalyticsLearnMoreDialog, +} from "../components/views/dialogs/AnalyticsLearnMoreDialog"; +import { Action } from "../dispatcher/actions"; const onAccept = () => { dis.dispatch({ - action: 'accept_cookies', + action: Action.PseudonymousAnalyticsAccept, }); }; const onReject = () => { dis.dispatch({ - action: "reject_cookies", + action: Action.PseudonymousAnalyticsReject, + }); +}; + +const onLearnMoreNoOptIn = () => { + showAnalyticsLearnMoreDialog({ + onFinished: (buttonClicked?: ButtonClicked) => { + if (buttonClicked === ButtonClicked.Primary) { + // user clicked "Enable" + onAccept(); + } + // otherwise, the user either clicked "Cancel", or closed the dialog without making a choice, + // leave the toast open + }, + primaryButton: _t("Enable"), + }); +}; + +const onLearnMorePreviouslyOptedIn = () => { + showAnalyticsLearnMoreDialog({ + onFinished: (buttonClicked?: ButtonClicked) => { + if (buttonClicked === ButtonClicked.Primary) { + // user clicked "That's fine" + onAccept(); + } else if (buttonClicked === ButtonClicked.Cancel) { + // user clicked "Stop" + onReject(); + } + // otherwise, the user closed the dialog without making a choice, leave the toast open + }, + primaryButton: _t("That's fine"), + cancelButton: _t("Stop"), }); }; @@ -42,39 +78,89 @@ const onUsageDataClicked = () => { const TOAST_KEY = "analytics"; -export const showToast = (policyUrl?: string) => { +const getAnonymousDescription = (): ReactNode => { + // get toast description for anonymous tracking (the previous scheme pre-posthog) const brand = SdkConfig.get().brand; + const cookiePolicyUrl = SdkConfig.get().piwik?.policyUrl; + return _t( + "Send anonymous usage data which helps us improve %(brand)s. " + + "This will use a cookie.", + { + brand, + }, + { + "UsageDataLink": (sub) => ( + { sub } + ), + "PolicyLink": (sub) => cookiePolicyUrl ? ( + { sub } + ) : sub, + }, + ); +}; + +const showToast = (props: Omit, "toastKey">) => { + const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand; ToastStore.sharedInstance().addOrReplaceToast({ key: TOAST_KEY, - title: _t("Help us improve %(brand)s", { brand }), - props: { - description: _t( - "Send anonymous usage data which helps us improve %(brand)s. " + - "This will use a cookie.", - { - brand, - }, - { - "UsageDataLink": (sub) => ( - { sub } - ), - // XXX: We need to link to the page that explains our cookies - "PolicyLink": (sub) => policyUrl ? ( - { sub } - ) : sub, - }, - ), - acceptLabel: _t("Yes"), - onAccept, - rejectLabel: _t("No"), - onReject, - }, + title: _t("Help improve %(analyticsOwner)s", { analyticsOwner }), + props, component: GenericToast, className: "mx_AnalyticsToast", priority: 10, }); }; +export const showPseudonymousAnalyticsOptInToast = (analyticsOptIn: boolean): void => { + let props; + if (analyticsOptIn) { + // The user previously opted into our old analytics system - let them know things have changed and ask + // them to opt in again. + props = { + description: _t( + "You previously consented to share anonymous usage data with us. We're updating how that works."), + acceptLabel: _t("That's fine"), + onAccept, + rejectLabel: _t("Learn more"), + onReject: onLearnMorePreviouslyOptedIn, + }; + } else if (analyticsOptIn === null || analyticsOptIn === undefined) { + // The user had no analytics setting previously set, so we just need to prompt to opt-in, rather than + // explaining any change. + const learnMoreLink = (sub) => ( + { sub } + ); + props = { + description: _t( + "Share anonymous data to help us identify issues. Nothing personal. No third parties. " + + "Learn More", {}, { "LearnMoreLink": learnMoreLink }), + acceptLabel: _t("Yes"), + onAccept, + rejectLabel: _t("No"), + onReject, + }; + } else { // false + // The user previously opted out of analytics, don't ask again + return; + } + showToast(props); +}; + +export const showAnonymousAnalyticsOptInToast = (): void => { + const props = { + description: getAnonymousDescription(), + acceptLabel: _t("Yes"), + onAccept: () => dis.dispatch({ + action: Action.AnonymousAnalyticsAccept, + }), + rejectLabel: _t("No"), + onReject: () => dis.dispatch({ + action: Action.AnonymousAnalyticsReject, + }), + }; + showToast(props); +}; + export const hideToast = () => { ToastStore.sharedInstance().dismissToast(TOAST_KEY); }; diff --git a/test/end-to-end-tests/src/scenarios/toast.js b/test/end-to-end-tests/src/scenarios/toast.js index f7f4e39b5d..b6142d8c3f 100644 --- a/test/end-to-end-tests/src/scenarios/toast.js +++ b/test/end-to-end-tests/src/scenarios/toast.js @@ -25,7 +25,7 @@ module.exports = async function toastScenarios(alice, bob) { alice.log.done(); alice.log.step(`accepts analytics toast`); - await acceptToast(alice, "Help us improve Element"); + await acceptToast(alice, "Help improve Element"); await rejectToast(alice, "Testing small changes"); alice.log.done(); @@ -40,7 +40,7 @@ module.exports = async function toastScenarios(alice, bob) { bob.log.done(); bob.log.step(`reject analytics toast`); - await rejectToast(bob, "Help us improve Element"); + await rejectToast(bob, "Help improve Element"); await rejectToast(bob, "Testing small changes"); bob.log.done();