- { _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 ef7b7b33de6..9fd32d51e51 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 f071a562414..5619d3f0b2a 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 28e801daf0a..98d22033217 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 00000000000..6c60c461756
--- /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 ed560b8929f..21977b36dc4 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 6291e86a708..4e49c1e61bc 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 02123310158..1154d242ada 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 346af1c766b..61327a1df09 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 a82b9685ef4..00000000000
--- 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 9ae3176fb83..dca7102535f 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 5a7737b1a6b..1072ae2907f 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,37 +78,87 @@ 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: {
+ 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(
- "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,
- },
- ),
+ "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,
- },
- component: GenericToast,
- className: "mx_AnalyticsToast",
- priority: 10,
- });
+ };
+ } 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 = () => {
diff --git a/test/end-to-end-tests/src/scenarios/toast.js b/test/end-to-end-tests/src/scenarios/toast.js
index f7f4e39b5dd..b6142d8c3fc 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();
|