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.
This commit is contained in:
James Salter 2021-12-06 09:39:33 +11:00 committed by GitHub
parent 961fec9081
commit 5219b6be80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 512 additions and 150 deletions

View file

@ -69,6 +69,7 @@
@import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddExistingToSpaceDialog.scss";
@import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss";
@import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_Analytics.scss";
@import "./views/dialogs/_AnalyticsLearnMoreDialog.scss";
@import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss";
@import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss";
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";

View file

@ -16,4 +16,8 @@ limitations under the License.
.mx_AnalyticsModal table { .mx_AnalyticsModal table {
margin: 10px 0px; margin: 10px 0px;
.mx_AnalyticsModal_label {
width: 400px;
}
} }

View file

@ -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;
}
}

View file

@ -15,13 +15,17 @@ limitations under the License.
*/ */
.mx_AnalyticsToast { .mx_AnalyticsToast {
.mx_AccessibleButton_kind_danger { .mx_AccessibleButton_kind_danger_outline {
background: none; background-color: $accent;
color: $accent; color: #ffffff;
border: 1px solid $accent;
font-weight: $font-semi-bold;
} }
.mx_AccessibleButton_kind_primary { .mx_AccessibleButton_kind_primary {
background: $accent; background-color: $accent;
color: #ffffff; color: #ffffff;
border: 1px solid $accent;
font-weight: $font-semi-bold;
} }
} }

15
res/img/element-shiny.svg Normal file
View file

@ -0,0 +1,15 @@
<svg width="128" height="101" viewBox="0 0 128 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="15" y="5" width="96" height="96" rx="48" fill="#0DBD8B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.1775 28.415C55.1775 26.5635 56.6785 25.0625 58.53 25.0625C70.8736 25.0625 80.88 35.0689 80.88 47.4125C80.88 49.264 79.379 50.765 77.5275 50.765C75.676 50.765 74.175 49.264 74.175 47.4125C74.175 38.772 67.1705 31.7675 58.53 31.7675C56.6785 31.7675 55.1775 30.2665 55.1775 28.415Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.8225 77.585C70.8225 79.4365 69.3215 80.9375 67.47 80.9375C55.1264 80.9375 45.12 70.9311 45.12 58.5875C45.12 56.736 46.621 55.235 48.4725 55.235C50.324 55.235 51.825 56.736 51.825 58.5875C51.825 67.228 58.8295 74.2325 67.47 74.2325C69.3215 74.2325 70.8225 75.7335 70.8225 77.585Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.415 60.8228C36.5635 60.8228 35.0625 59.3218 35.0625 57.4703C35.0625 45.1267 45.0689 35.1203 57.4125 35.1203C59.264 35.1203 60.765 36.6212 60.765 38.4727C60.765 40.3243 59.264 41.8253 57.4125 41.8253C48.772 41.8253 41.7675 48.8298 41.7675 57.4703C41.7675 59.3218 40.2665 60.8228 38.415 60.8228Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M87.585 45.1772C89.4365 45.1772 90.9375 46.6782 90.9375 48.5297C90.9375 60.8733 80.9311 70.8797 68.5875 70.8797C66.736 70.8797 65.235 69.3788 65.235 67.5272C65.235 65.6757 66.736 64.1747 68.5875 64.1747C77.228 64.1747 84.2325 57.1702 84.2325 48.5297C84.2325 46.6782 85.7335 45.1772 87.585 45.1772Z" fill="white"/>
<path d="M30.4463 73.0498C37.2216 73.9931 38.0613 74.7983 39.2002 81.8497C39.3267 82.5284 39.8558 83 40.4885 83C41.1442 83 41.6848 82.5169 41.7768 81.8382C42.7776 74.7868 43.7324 73.8206 50.5422 73.0498C51.2324 72.9693 51.75 72.4057 51.75 71.75C51.75 71.0828 51.2439 70.5422 50.5537 70.4502L41.7768 61.6503C41.6618 60.9716 41.1442 60.5 40.4885 60.5C39.8443 60.5 39.3037 60.9716 39.2002 61.6618C38.2109 68.7132 37.2561 69.6794 30.4463 70.4502C29.7561 70.5307 29.25 71.0828 29.25 71.75C29.25 72.4057 29.7446 72.9578 30.4463 73.0498ZM30.4463 73.0498L30.2783 74.257C30.2796 74.2571 30.281 74.2573 30.2823 74.2575M30.4463 73.0498L30.2878 74.2582C30.286 74.258 30.2842 74.2578 30.2823 74.2575M30.2823 74.2575C31.9721 74.4928 33.2099 74.7097 34.1585 74.9974C35.0904 75.2801 35.6632 75.6086 36.0746 76.0205C36.4888 76.4352 36.833 77.0281 37.1435 78.0026C37.4582 78.9903 37.7127 80.284 37.997 82.044L37.9993 82.0586L38.002 82.0731C38.2222 83.2538 39.1958 84.2188 40.4885 84.2188C41.7834 84.2188 42.8124 83.256 42.9839 82.0063C43.2331 80.2515 43.4698 78.9519 43.7759 77.957C44.0782 76.9743 44.4247 76.3703 44.8444 75.949C45.7105 75.0799 47.1972 74.6549 50.6792 74.2609L50.6834 74.2604C51.945 74.1132 52.9688 73.0664 52.9688 71.75C52.9688 70.4139 51.951 69.4069 50.7148 69.2421L50.7117 69.2417C49.0219 69.0208 47.7825 68.8106 46.8326 68.5257C45.9005 68.2462 45.3243 67.9163 44.9096 67.5011C44.4915 67.0825 44.1442 66.484 43.8322 65.5053C43.5161 64.5138 43.2615 63.2169 42.9803 61.4579L42.9804 61.4579L42.9785 61.4466C42.775 60.2465 41.802 59.2812 40.4885 59.2812C39.2028 59.2812 38.1821 60.2329 37.9949 61.481L37.9948 61.481L37.9932 61.4925C37.7468 63.2488 37.512 64.5493 37.2073 65.5446C36.9065 66.5271 36.561 67.1307 36.1421 67.5515C35.2777 68.42 33.7916 68.845 30.3093 69.2391L30.3093 69.2391L30.3051 69.2396C29.0355 69.3877 28.0312 70.4298 28.0312 71.75C28.0312 73.0579 29.0195 74.0893 30.2823 74.2575Z" fill="white" stroke="#0DBD8B" stroke-width="2.4375"/>
<path d="M94.7208 30.328L94.7236 30.3276C95.6801 30.2149 96.4375 29.4177 96.4375 28.4375C96.4375 27.4421 95.6859 26.6756 94.7465 26.5491L94.7444 26.5488C93.3498 26.3646 92.3142 26.188 91.5149 25.9458C90.7278 25.7074 90.2204 25.4198 89.848 25.0432C89.4729 24.6639 89.173 24.1319 88.9097 23.2975C88.6437 22.455 88.4318 21.3606 88.1997 19.8941L88.1997 19.8941L88.1984 19.8866C88.0461 18.9789 87.32 18.25 86.3343 18.25C85.3687 18.25 84.6075 18.9698 84.4677 19.9113L84.4677 19.9113L84.4666 19.9189C84.2632 21.3837 84.0677 22.4815 83.8111 23.328C83.557 24.1662 83.2586 24.7031 82.8824 25.0849C82.114 25.8648 80.8173 26.2201 77.9572 26.5471L77.9572 26.547L77.9544 26.5474C76.9927 26.6607 76.25 27.4529 76.25 28.4375C76.25 29.4121 76.9802 30.1969 77.939 30.3257C79.3337 30.5218 80.3679 30.7041 81.1658 30.9486C81.9523 31.1896 82.4569 31.4761 82.8265 31.8498C83.1984 32.2259 83.4957 32.7533 83.7578 33.5841C84.0226 34.4234 84.2345 35.515 84.4691 36.9822L84.4706 36.9918L84.4724 37.0014C84.6376 37.8963 85.3662 38.625 86.3343 38.625C87.3079 38.625 88.0744 37.8957 88.202 36.9553C88.4077 35.4915 88.6048 34.3942 88.8626 33.5481C89.1179 32.7097 89.4172 32.1725 89.7941 31.7904C90.5638 31.0102 91.861 30.6549 94.7208 30.328Z" fill="white" stroke="#0DBD8B" stroke-width="1.625"/>
<path d="M121.494 20C121.844 20 122.132 19.7423 122.181 19.3804C122.715 15.6196 123.224 15.1043 126.856 14.6933C127.224 14.6503 127.5 14.3497 127.5 14C127.5 13.6442 127.23 13.3558 126.862 13.3067C123.248 12.8344 122.782 12.3742 122.181 8.6135C122.12 8.25153 121.844 8 121.494 8C121.15 8 120.862 8.25153 120.807 8.61963C120.279 12.3804 119.77 12.8957 116.138 13.3067C115.77 13.3497 115.5 13.6442 115.5 14C115.5 14.3497 115.764 14.6442 116.138 14.6933C119.752 15.1963 120.199 15.6258 120.807 19.3865C120.874 19.7485 121.156 20 121.494 20Z" fill="#0DBD8B"/>
<path d="M114.372 9.5C114.568 9.5 114.73 9.35506 114.758 9.15146C115.058 7.03604 115.345 6.74617 117.388 6.51495C117.595 6.4908 117.75 6.3217 117.75 6.125C117.75 5.92485 117.598 5.76265 117.391 5.73505C115.359 5.46933 115.096 5.21051 114.758 3.09509C114.724 2.89149 114.568 2.75 114.372 2.75C114.178 2.75 114.016 2.89149 113.985 3.09854C113.688 5.21396 113.402 5.50383 111.359 5.73505C111.152 5.7592 111 5.92485 111 6.125C111 6.3217 111.148 6.48735 111.359 6.51495C113.391 6.79793 113.643 7.03949 113.985 9.15491C114.023 9.35851 114.182 9.5 114.372 9.5Z" fill="#0DBD8B"/>
<path d="M115.871 26C116.111 26 116.309 25.8229 116.343 25.574C116.71 22.9885 117.06 22.6342 119.557 22.3516C119.81 22.3221 120 22.1154 120 21.875C120 21.6304 119.814 21.4321 119.561 21.3984C117.077 21.0736 116.757 20.7573 116.343 18.1718C116.301 17.9229 116.111 17.75 115.871 17.75C115.635 17.75 115.436 17.9229 115.398 18.176C115.036 20.7615 114.686 21.1158 112.189 21.3984C111.936 21.4279 111.75 21.6304 111.75 21.875C111.75 22.1154 111.931 22.3179 112.189 22.3516C114.673 22.6975 114.981 22.9927 115.398 25.5782C115.445 25.8271 115.639 26 115.871 26Z" fill="#0DBD8B"/>
<path d="M5.61925 16.25C5.94709 16.25 6.21741 16.0084 6.26342 15.6691C6.7638 12.1434 7.24118 11.6603 10.6461 11.2749C10.9912 11.2347 11.25 10.9528 11.25 10.625C11.25 10.2914 10.9969 10.0211 10.6518 9.97508C7.26419 9.53221 6.82707 9.10084 6.26342 5.57515C6.20591 5.23581 5.94709 5 5.61925 5C5.29716 5 5.02684 5.23581 4.97508 5.5809C4.48045 9.1066 4.00307 9.58972 0.59816 9.97508C0.253068 10.0153 0 10.2914 0 10.625C0 10.9528 0.247316 11.2289 0.59816 11.2749C3.98581 11.7465 4.40568 12.1492 4.97508 15.6748C5.03834 16.0142 5.30291 16.25 5.61925 16.25Z" fill="#0DBD8B"/>
<path opacity="0.4" d="M13.1223 5.75C13.2753 5.75 13.4015 5.63727 13.4229 5.47891C13.6564 3.83359 13.8792 3.60813 15.4682 3.4283C15.6292 3.40951 15.75 3.27799 15.75 3.125C15.75 2.96933 15.6319 2.84317 15.4709 2.8217C13.89 2.61503 13.686 2.41373 13.4229 0.768405C13.3961 0.610046 13.2753 0.5 13.1223 0.5C12.972 0.5 12.8459 0.610046 12.8217 0.771089C12.5909 2.41641 12.3681 2.64187 10.7791 2.8217C10.6181 2.84049 10.5 2.96933 10.5 3.125C10.5 3.27799 10.6154 3.40683 10.7791 3.4283C12.36 3.64839 12.556 3.83627 12.8217 5.4816C12.8512 5.63995 12.9747 5.75 13.1223 5.75Z" fill="#0DBD8B"/>
<path opacity="0.4" d="M16.8723 12.5C17.0253 12.5 17.1515 12.3873 17.1729 12.2289C17.4064 10.5836 17.6292 10.3581 19.2182 10.1783C19.3792 10.1595 19.5 10.028 19.5 9.875C19.5 9.71933 19.3819 9.59317 19.2209 9.5717C17.64 9.36503 17.436 9.16373 17.1729 7.5184C17.1461 7.36005 17.0253 7.25 16.8723 7.25C16.722 7.25 16.5959 7.36005 16.5717 7.52109C16.3409 9.16641 16.1181 9.39187 14.5291 9.5717C14.3681 9.59049 14.25 9.71933 14.25 9.875C14.25 10.028 14.3654 10.1568 14.5291 10.1783C16.11 10.3984 16.306 10.5863 16.5717 12.2316C16.6012 12.39 16.7247 12.5 16.8723 12.5Z" fill="#0DBD8B"/>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

4
res/img/tick-circle.svg Normal file
View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2V2Z" stroke="#0DBD8B" stroke-width="2" stroke-linecap="square"/>
<path d="M6.54549 12.8882L9.80306 16.2426L17.4546 8.36377" stroke="#0DBD8B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 442 B

View file

@ -393,16 +393,26 @@ export class Analytics {
]; ];
// FIXME: Using an import will result in test failures // FIXME: Using an import will result in test failures
const cookiePolicyUrl = SdkConfig.get().piwik?.policyUrl;
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
const cookiePolicyLink = _t(
"Our complete cookie policy can be found <CookiePolicyLink>here</CookiePolicyLink>.",
{},
{
"CookiePolicyLink": (sub) => {
return <a href={cookiePolicyUrl} target="_blank" rel="noreferrer noopener">{ sub }</a>;
},
});
Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, { Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
title: _t('Analytics'), title: _t('Analytics'),
description: <div className="mx_AnalyticsModal"> description: <div className="mx_AnalyticsModal">
<div>{ _t('The information being sent to us to help make %(brand)s better includes:', { { cookiePolicyUrl && <p>{ cookiePolicyLink }</p> }
<div>{ _t('Some examples of the information being sent to us to help make %(brand)s better includes:', {
brand: SdkConfig.get().brand, brand: SdkConfig.get().brand,
}) }</div> }) }</div>
<table> <table>
{ rows.map((row) => <tr key={row[0]}> { rows.map((row) => <tr key={row[0]}>
<td>{ _t( <td className="mx_AnalyticsModal_label">{ _t(
customVariables[row[0]].expl, customVariables[row[0]].expl,
customVariables[row[0]].getTextVariables ? customVariables[row[0]].getTextVariables ?
customVariables[row[0]].getTextVariables() : customVariables[row[0]].getTextVariables() :

View file

@ -585,12 +585,13 @@ async function doSetLoggedIn(
MatrixClientPeg.replaceUsingCreds(credentials); MatrixClientPeg.replaceUsingCreds(credentials);
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
setSentryUser(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 (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
// If we just logged in, try to rehydrate a device instead of using a // 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 // new device. If it succeeds, we'll get a new device ID, so make sure

View file

@ -17,11 +17,11 @@ limitations under the License.
import posthog, { PostHog } from 'posthog-js'; import posthog, { PostHog } from 'posthog-js';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import SettingsStore from './settings/SettingsStore';
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "./settings/SettingsStore";
/* Posthog analytics tracking. /* Posthog analytics tracking.
* *
@ -132,10 +132,10 @@ export class PosthogAnalytics {
private anonymity = Anonymity.Disabled; private anonymity = Anonymity.Disabled;
// set true during the constructor if posthog config is present, otherwise false // 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 static _instance = null;
private platformSuperProperties = {}; 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 { public static get instance(): PosthogAnalytics {
if (!this._instance) { if (!this._instance) {
@ -197,29 +197,6 @@ export class PosthogAnalytics {
return properties; 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) { private registerSuperProperties(properties: posthog.Properties) {
if (this.enabled) { if (this.enabled) {
this.posthog.register(properties); 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 // 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. // different devices to send the same ID.
try { try {
const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_ID_EVENT_TYPE); const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_EVENT_TYPE);
let analyticsID = accountData?.id; let analyticsID = accountData?.id;
if (!analyticsID) { if (!analyticsID) {
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server. // 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 // 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. // page load). This will happen pretty infrequently, so we can tolerate the possibility.
analyticsID = analyticsIdGenerator(); 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); this.posthog.identify(analyticsID);
} catch (e) { } catch (e) {
@ -307,7 +285,7 @@ export class PosthogAnalytics {
if (this.enabled) { if (this.enabled) {
this.posthog.reset(); this.posthog.reset();
} }
this.setAnonymity(Anonymity.Anonymous); this.setAnonymity(Anonymity.Disabled);
} }
public async trackPseudonymousEvent<E extends IPseudonymousEvent>( public async trackPseudonymousEvent<E extends IPseudonymousEvent>(
@ -351,12 +329,31 @@ export class PosthogAnalytics {
this.registerSuperProperties(this.platformSuperProperties); this.registerSuperProperties(this.platformSuperProperties);
} }
public async updateAnonymityFromSettings(userId?: string): Promise<void> { public async updateAnonymityFromSettings(pseudonymousOptIn: boolean): Promise<void> {
// Update this.anonymity based on the user's analytics opt-in settings // 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 const anonymity = pseudonymousOptIn ? Anonymity.Pseudonymous : Anonymity.Disabled;
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); this.setAnonymity(anonymity);
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { if (anonymity === Anonymity.Pseudonymous) {
await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId); 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);
});
} }
} }

View file

@ -18,7 +18,7 @@ import React, { ComponentType, createRef } from 'react';
import { createClient } from "matrix-js-sdk/src/matrix"; import { createClient } from "matrix-js-sdk/src/matrix";
import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; 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 // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
import 'focus-visible'; import 'focus-visible';
@ -59,8 +59,9 @@ import * as StorageManager from "../../utils/StorageManager";
import type LoggedInViewType from "./LoggedInView"; import type LoggedInViewType from "./LoggedInView";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { import {
showToast as showAnalyticsToast,
hideToast as hideAnalyticsToast, hideToast as hideAnalyticsToast,
showAnonymousAnalyticsOptInToast,
showPseudonymousAnalyticsOptInToast,
} from "../../toasts/AnalyticsToast"; } from "../../toasts/AnalyticsToast";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
@ -382,13 +383,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
} }
if (SettingsStore.getValue("analyticsOptIn")) { if (SettingsStore.getValue("pseudonymousAnalyticsOptIn")) {
Analytics.enable(); Analytics.enable();
} }
PosthogAnalytics.instance.updateAnonymityFromSettings();
PosthogAnalytics.instance.updatePlatformSuperProperties();
CountlyAnalytics.instance.enable(/* anonymous = */ true); CountlyAnalytics.instance.enable(/* anonymous = */ true);
initSentry(SdkConfig.get()["sentry"]); initSentry(SdkConfig.get()["sentry"]);
@ -500,8 +498,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else { } else {
dis.dispatch({ action: "view_welcome_page" }); 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 // Note we don't catch errors from this: we catch everything within
@ -816,10 +812,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
hideToSRUsers: false, hideToSRUsers: false,
}); });
break; break;
case 'accept_cookies': case Action.AnonymousAnalyticsAccept:
hideAnalyticsToast();
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true); SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false); SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
hideAnalyticsToast();
if (Analytics.canEnable()) { if (Analytics.canEnable()) {
Analytics.enable(); Analytics.enable();
} }
@ -827,10 +823,18 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
CountlyAnalytics.instance.enable(/* anonymous = */ false); CountlyAnalytics.instance.enable(/* anonymous = */ false);
} }
break; break;
case 'reject_cookies': case Action.AnonymousAnalyticsReject:
hideAnalyticsToast();
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false); SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
break;
case Action.PseudonymousAnalyticsAccept:
hideAnalyticsToast(); hideAnalyticsToast();
SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, true);
break;
case Action.PseudonymousAnalyticsReject:
hideAnalyticsToast();
SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, false);
break; break;
} }
}; };
@ -1323,13 +1327,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
StorageManager.tryPersistStorage(); StorageManager.tryPersistStorage();
// defer the following actions by 30 seconds to not throw them at the user immediately if (PosthogAnalytics.instance.isEnabled()) {
await sleep(30); this.initPosthogAnalyticsToast();
} else if (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) {
if (SettingsStore.getValue("showCookieBar") && if (SettingsStore.getValue("showCookieBar") &&
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) (Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
) { ) {
showAnalyticsToast(this.props.config.piwik?.policyUrl); showAnonymousAnalyticsOptInToast();
} }
}
if (SdkConfig.get().mobileGuideToast) { if (SdkConfig.get().mobileGuideToast) {
// The toast contains further logic to detect mobile platforms, // The toast contains further logic to detect mobile platforms,
// check if it has been dismissed before, etc. // check if it has been dismissed before, etc.
@ -1337,6 +1344,34 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
} }
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() { private showScreenAfterLogin() {
// If screenAfterLogin is set, use that, then null it so that a second login will // 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 // result in view_home_page, _user_settings or _room_directory

View file

@ -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<IProps> = ({
onFinished,
analyticsOwner,
privacyPolicyUrl,
primaryButton,
cancelButton,
hasCancel,
}) => {
const onPrimaryButtonClick = () => onFinished && onFinished(ButtonClicked.Primary);
const onCancelButtonClick = () => onFinished && onFinished(ButtonClicked.Cancel);
const privacyPolicyLink = privacyPolicyUrl ?
<span>
{
_t("You can read all our terms <PrivacyPolicyUrl>here</PrivacyPolicyUrl>", {}, {
"PrivacyPolicyUrl": (sub) => {
return <a href={privacyPolicyUrl}
rel="norefferer noopener"
target="_blank"
>
{ sub }
<span className="mx_AnalyticsPolicyLink" />
</a>;
},
})
}
</span> : "";
return <BaseDialog
className="mx_AnalyticsLearnMoreDialog"
contentId="mx_AnalyticsLearnMore"
title={_t("Help improve %(analyticsOwner)s", { analyticsOwner })}
onFinished={onFinished}
>
<div className="mx_Dialog_content">
<div className="mx_AnalyticsLearnMore_image_holder" />
<div className="mx_AnalyticsLearnMore_copy">
{ _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.",
) }
</div>
<ul className="mx_AnalyticsLearnMore_bullets">
<li>{ _t("We <Bold>don't</Bold> record or profile any account data",
{}, { "Bold": (sub) => <b>{ sub }</b> }) }</li>
<li>{ _t("We <Bold>don't</Bold> share information with third parties",
{}, { "Bold": (sub) => <b>{ sub }</b> }) }</li>
<li>{ _t("You can turn this off anytime in settings") }</li>
</ul>
{ privacyPolicyLink }
</div>
<DialogButtons
primaryButton={primaryButton}
cancelButton={cancelButton}
onPrimaryButtonClick={onPrimaryButtonClick}
onCancel={onCancelButtonClick}
hasCancel={hasCancel}
/>
</BaseDialog>;
};
export const showDialog = (props: Omit<IProps, "cookiePolicyUrl" | "analyticsOwner">): 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;

View file

@ -19,7 +19,6 @@ import React from 'react';
import { sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import Analytics from "../../../../../Analytics"; import Analytics from "../../../../../Analytics";
@ -32,7 +31,6 @@ import { UIFeature } from "../../../../../settings/UIFeature";
import E2eAdvancedPanel, { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import E2eAdvancedPanel, { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel";
import CountlyAnalytics from "../../../../../CountlyAnalytics"; import CountlyAnalytics from "../../../../../CountlyAnalytics";
import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
import { ActionPayload } from "../../../../../dispatcher/payloads"; import { ActionPayload } from "../../../../../dispatcher/payloads";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import CryptographyPanel from "../../CryptographyPanel"; import CryptographyPanel from "../../CryptographyPanel";
@ -41,8 +39,10 @@ import SettingsFlag from "../../../elements/SettingsFlag";
import CrossSigningPanel from "../../CrossSigningPanel"; import CrossSigningPanel from "../../CrossSigningPanel";
import EventIndexPanel from "../../EventIndexPanel"; import EventIndexPanel from "../../EventIndexPanel";
import InlineSpinner from "../../../elements/InlineSpinner"; import InlineSpinner from "../../../elements/InlineSpinner";
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { showDialog as showAnalyticsLearnMoreDialog } from "../../../dialogs/AnalyticsLearnMoreDialog";
interface IIgnoredUserProps { interface IIgnoredUserProps {
userId: string; userId: string;
@ -118,7 +118,6 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
private updateAnalytics = (checked: boolean): void => { private updateAnalytics = (checked: boolean): void => {
checked ? Analytics.enable() : Analytics.disable(); checked ? Analytics.enable() : Analytics.disable();
CountlyAnalytics.instance.enable(/* anonymous = */ !checked); CountlyAnalytics.instance.enable(/* anonymous = */ !checked);
PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId());
}; };
private onMyMembership = (room: Room, membership: string): void => { private onMyMembership = (room: Room, membership: string): void => {
@ -272,8 +271,6 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
} }
public render(): JSX.Element { public render(): JSX.Element {
const brand = SdkConfig.get().brand;
const secureBackup = ( const secureBackup = (
<div className='mx_SettingsTab_section'> <div className='mx_SettingsTab_section'>
<span className="mx_SettingsTab_subheading">{ _t("Secure Backup") }</span> <span className="mx_SettingsTab_subheading">{ _t("Secure Backup") }</span>
@ -312,24 +309,41 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
} }
let privacySection; let privacySection;
if (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) { if (Analytics.canEnable() || CountlyAnalytics.instance.canEnable() || PosthogAnalytics.instance.isEnabled()) {
const onClickAnalyticsLearnMore = () => {
if (PosthogAnalytics.instance.isEnabled()) {
showAnalyticsLearnMoreDialog({
primaryButton: _t("Okay"),
hasCancel: false,
});
} else {
Analytics.showDetailsModal();
}
};
privacySection = <React.Fragment> privacySection = <React.Fragment>
<div className="mx_SettingsTab_heading">{ _t("Privacy") }</div> <div className="mx_SettingsTab_heading">{ _t("Privacy") }</div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Analytics") }</span> <span className="mx_SettingsTab_subheading">{ _t("Analytics") }</span>
<div className="mx_SettingsTab_subsectionText"> <div className="mx_SettingsTab_subsectionText">
{ _t( <p>
"%(brand)s collects anonymous analytics to allow us to improve the application.", { _t("Share anonymous data to help us identify issues. Nothing personal. " +
{ brand }, "No third parties.") }
) } </p>
&nbsp; <p>
{ _t("Privacy is important to us, so we don't collect any personal or " + <AccessibleButton className="mx_SettingsTab_linkBtn" onClick={onClickAnalyticsLearnMore}>
"identifiable data for our analytics.") } { _t("Learn more") }
<AccessibleButton className="mx_SettingsTab_linkBtn" onClick={Analytics.showDetailsModal}>
{ _t("Learn more about how we use analytics.") }
</AccessibleButton> </AccessibleButton>
</p>
</div> </div>
<SettingsFlag name="analyticsOptIn" level={SettingLevel.DEVICE} onChange={this.updateAnalytics} /> {
PosthogAnalytics.instance.isEnabled() ?
<SettingsFlag name="pseudonymousAnalyticsOptIn"
level={SettingLevel.ACCOUNT}
onChange={this.updateAnalytics} /> :
<SettingsFlag name="analyticsOptIn"
level={SettingLevel.DEVICE}
onChange={this.updateAnalytics} />
}
</div> </div>
</React.Fragment>; </React.Fragment>;
} }

View file

@ -203,4 +203,29 @@ export enum Action {
* Fires when a user starts to edit event (e.g. up arrow in compositor) * Fires when a user starts to edit event (e.g. up arrow in compositor)
*/ */
EditEvent = "edit_event", 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"
} }

View file

@ -28,8 +28,9 @@
"e.g. <CurrentPageURL>": "e.g. <CurrentPageURL>", "e.g. <CurrentPageURL>": "e.g. <CurrentPageURL>",
"Your user agent": "Your user agent", "Your user agent": "Your user agent",
"Your device resolution": "Your device resolution", "Your device resolution": "Your device resolution",
"Our complete cookie policy can be found <CookiePolicyLink>here</CookiePolicyLink>.": "Our complete cookie policy can be found <CookiePolicyLink>here</CookiePolicyLink>.",
"Analytics": "Analytics", "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.", "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", "Error": "Error",
"Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", "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", "Topic: %(topic)s": "Topic: %(topic)s",
"Error fetching file": "Error fetching file", "Error fetching file": "Error fetching file",
"File Attached": "File Attached", "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 <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. This will use a <PolicyLink>cookie</PolicyLink>.": "Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. This will use a <PolicyLink>cookie</PolicyLink>.", "Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. This will use a <PolicyLink>cookie</PolicyLink>.": "Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. This will use a <PolicyLink>cookie</PolicyLink>.",
"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. <LearnMoreLink>Learn More</LearnMoreLink>": "Share anonymous data to help us identify issues. Nothing personal. No third parties. <LearnMoreLink>Learn More</LearnMoreLink>",
"Yes": "Yes", "Yes": "Yes",
"No": "No", "No": "No",
"You have unverified logins": "You have unverified logins", "You have unverified logins": "You have unverified logins",
@ -759,7 +766,6 @@
"Don't miss a reply": "Don't miss a reply", "Don't miss a reply": "Don't miss a reply",
"Notifications": "Notifications", "Notifications": "Notifications",
"Enable desktop notifications": "Enable desktop notifications", "Enable desktop notifications": "Enable desktop notifications",
"Enable": "Enable",
"Unknown caller": "Unknown caller", "Unknown caller": "Unknown caller",
"Voice call": "Voice call", "Voice call": "Voice call",
"Video call": "Video 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 DMs": "Show message previews for reactions in DMs",
"Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "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", "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)", "Polls (under active development)": "Polls (under active development)",
"Show info about bridges in room settings": "Show info about bridges in room settings", "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)", "New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)",
@ -1447,10 +1452,9 @@
"Message search": "Message search", "Message search": "Message search",
"Cross-signing": "Cross-signing", "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.", "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", "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.", "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.",
"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.",
"Where you're signed in": "Where you're signed in", "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.", "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", "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.", "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. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.": "Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.", "Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.": "Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.",
"Use an identity server to invite by email. Manage in <settings>Settings</settings>.": "Use an identity server to invite by email. Manage in <settings>Settings</settings>.", "Use an identity server to invite by email. Manage in <settings>Settings</settings>.": "Use an identity server to invite by email. Manage in <settings>Settings</settings>.",
"You can read all our terms <PrivacyPolicyUrl>here</PrivacyPolicyUrl>": "You can read all our terms <PrivacyPolicyUrl>here</PrivacyPolicyUrl>",
"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 <Bold>don't</Bold> record or profile any account data": "We <Bold>don't</Bold> record or profile any account data",
"We <Bold>don't</Bold> share information with third parties": "We <Bold>don't</Bold> 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", "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?", "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", "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", "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.", "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.", "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", "Exporting your data": "Exporting your data",
"Export Chat": "Export Chat", "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", "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'.", "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", "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.", "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", "About homeservers": "About homeservers",
"Reset event store?": "Reset event store?", "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", "You most likely do not want to reset your event index store": "You most likely do not want to reset your event index store",

View file

@ -40,7 +40,6 @@ import { OrderedMultiController } from "./controllers/OrderedMultiController";
import { Layout } from "./enums/Layout"; import { Layout } from "./enums/Layout";
import ReducedMotionController from './controllers/ReducedMotionController'; import ReducedMotionController from './controllers/ReducedMotionController';
import IncompatibleController from "./controllers/IncompatibleController"; import IncompatibleController from "./controllers/IncompatibleController";
import PseudonymousAnalyticsController from './controllers/PseudonymousAnalyticsController';
import NewLayoutSwitcherController from './controllers/NewLayoutSwitcherController'; import NewLayoutSwitcherController from './controllers/NewLayoutSwitcherController';
import { ImageSize } from "./enums/ImageSize"; import { ImageSize } from "./enums/ImageSize";
import { MetaSpace } from "../stores/spaces"; import { MetaSpace } from "../stores/spaces";
@ -301,14 +300,6 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, 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": { "feature_polls": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,
@ -621,6 +612,11 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: true, default: true,
}, },
"pseudonymousAnalyticsOptIn": {
supportedLevels: [SettingLevel.ACCOUNT],
displayName: _td('Send analytics data'),
default: null,
},
"autocompleteDelay": { "autocompleteDelay": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: 200, default: 200,

View file

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

View file

@ -28,6 +28,7 @@ const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs";
const BREADCRUMBS_EVENT_TYPES = [BREADCRUMBS_LEGACY_EVENT_TYPE, BREADCRUMBS_EVENT_TYPE]; const BREADCRUMBS_EVENT_TYPES = [BREADCRUMBS_LEGACY_EVENT_TYPE, BREADCRUMBS_EVENT_TYPE];
const RECENT_EMOJI_EVENT_TYPE = "io.element.recent_emoji"; const RECENT_EMOJI_EVENT_TYPE = "io.element.recent_emoji";
const INTEG_PROVISIONING_EVENT_TYPE = "im.vector.setting.integration_provisioning"; 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. * 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); 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 // Figure out what changed and fire those updates
const prevContent = prevEvent ? prevEvent.getContent() : {}; const prevContent = prevEvent ? prevEvent.getContent() : {};
const changedSettings = objectKeyChanges<Record<string, any>>(prevContent, event.getContent()); const changedSettings = objectKeyChanges<Record<string, any>>(prevContent, event.getContent());
@ -127,6 +128,13 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
return value; 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() || {}; const settings = this.getSettings() || {};
let preferredValue = settings[settingName]; let preferredValue = settings[settingName];
@ -179,6 +187,14 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
return; 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() || {}; const content = this.getSettings() || {};
content[settingName] = newValue; content[settingName] = newValue;
await MatrixClientPeg.get().setAccountData("im.vector.web.settings", content); await MatrixClientPeg.get().setAccountData("im.vector.web.settings", content);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { ReactNode } from "react";
import { _t } from "../languageHandler"; import { _t } from "../languageHandler";
import SdkConfig from "../SdkConfig"; import SdkConfig from "../SdkConfig";
@ -23,16 +23,52 @@ import Analytics from "../Analytics";
import AccessibleButton from "../components/views/elements/AccessibleButton"; import AccessibleButton from "../components/views/elements/AccessibleButton";
import GenericToast from "../components/views/toasts/GenericToast"; import GenericToast from "../components/views/toasts/GenericToast";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
import {
ButtonClicked,
showDialog as showAnalyticsLearnMoreDialog,
} from "../components/views/dialogs/AnalyticsLearnMoreDialog";
import { Action } from "../dispatcher/actions";
const onAccept = () => { const onAccept = () => {
dis.dispatch({ dis.dispatch({
action: 'accept_cookies', action: Action.PseudonymousAnalyticsAccept,
}); });
}; };
const onReject = () => { const onReject = () => {
dis.dispatch({ 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,13 +78,11 @@ const onUsageDataClicked = () => {
const TOAST_KEY = "analytics"; 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 brand = SdkConfig.get().brand;
ToastStore.sharedInstance().addOrReplaceToast({ const cookiePolicyUrl = SdkConfig.get().piwik?.policyUrl;
key: TOAST_KEY, return _t(
title: _t("Help us improve %(brand)s", { brand }),
props: {
description: _t(
"Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. " + "Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. " +
"This will use a <PolicyLink>cookie</PolicyLink>.", "This will use a <PolicyLink>cookie</PolicyLink>.",
{ {
@ -58,23 +92,75 @@ export const showToast = (policyUrl?: string) => {
"UsageDataLink": (sub) => ( "UsageDataLink": (sub) => (
<AccessibleButton kind="link" onClick={onUsageDataClicked}>{ sub }</AccessibleButton> <AccessibleButton kind="link" onClick={onUsageDataClicked}>{ sub }</AccessibleButton>
), ),
// XXX: We need to link to the page that explains our cookies "PolicyLink": (sub) => cookiePolicyUrl ? (
"PolicyLink": (sub) => policyUrl ? ( <a target="_blank" href={cookiePolicyUrl}>{ sub }</a>
<a target="_blank" href={policyUrl}>{ sub }</a>
) : sub, ) : sub,
}, },
), );
acceptLabel: _t("Yes"), };
onAccept,
rejectLabel: _t("No"), const showToast = (props: Omit<React.ComponentProps<typeof GenericToast>, "toastKey">) => {
onReject, const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand;
}, ToastStore.sharedInstance().addOrReplaceToast({
key: TOAST_KEY,
title: _t("Help improve %(analyticsOwner)s", { analyticsOwner }),
props,
component: GenericToast, component: GenericToast,
className: "mx_AnalyticsToast", className: "mx_AnalyticsToast",
priority: 10, 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) => (
<AccessibleButton kind="link" onClick={onLearnMoreNoOptIn}>{ sub }</AccessibleButton>
);
props = {
description: _t(
"Share anonymous data to help us identify issues. Nothing personal. No third parties. " +
"<LearnMoreLink>Learn More</LearnMoreLink>", {}, { "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 = () => { export const hideToast = () => {
ToastStore.sharedInstance().dismissToast(TOAST_KEY); ToastStore.sharedInstance().dismissToast(TOAST_KEY);
}; };

View file

@ -25,7 +25,7 @@ module.exports = async function toastScenarios(alice, bob) {
alice.log.done(); alice.log.done();
alice.log.step(`accepts analytics toast`); 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"); await rejectToast(alice, "Testing small changes");
alice.log.done(); alice.log.done();
@ -40,7 +40,7 @@ module.exports = async function toastScenarios(alice, bob) {
bob.log.done(); bob.log.done();
bob.log.step(`reject analytics toast`); 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"); await rejectToast(bob, "Testing small changes");
bob.log.done(); bob.log.done();