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();