diff --git a/CHANGELOG.md b/CHANGELOG.md index d90baa9c33..8c63a79a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,7 @@ ### Features - -### Fixes - +- Added analytics data collection ([PR 2927](https://github.com/input-output-hk/daedalus/pull/2927), [PR 2989](https://github.com/input-output-hk/daedalus/pull/2989), [PR 3003](https://github.com/input-output-hk/daedalus/pull/3003), [PR 3028](https://github.com/input-output-hk/daedalus/pull/3028)) ### Chores @@ -17,7 +15,6 @@ ### Features - Added new Mnemonic input component ([PR 2979](https://github.com/input-output-hk/daedalus/pull/2979)) -- Updated Terms of Service ([PR 3009](https://github.com/input-output-hk/daedalus/pull/3009)) ### Fixes @@ -32,6 +29,10 @@ - Fix `darwin-launcher.go` to replace its process image with `cardano-launcher` (binary), and not swallow `stdout` ([PR 3023](https://github.com/input-output-hk/daedalus/pull/3023)) - Updated cardano-node to 1.35.1 ([PR 3012](https://github.com/input-output-hk/daedalus/pull/3012)) +### Features + +- Updated Terms of Service ([PR 3009](https://github.com/input-output-hk/daedalus/pull/3009)) + ## 4.12.0 ### Fixes diff --git a/matomo.docker-compose.yml b/matomo.docker-compose.yml new file mode 100644 index 0000000000..326673539b --- /dev/null +++ b/matomo.docker-compose.yml @@ -0,0 +1,32 @@ +version: "3" +services: + mariadb: + image: docker.io/bitnami/mariadb:10.6 + environment: + - ALLOW_EMPTY_PASSWORD=yes + - MARIADB_USER=bn_matomo + - MARIADB_DATABASE=bitnami_matomo + - MARIADB_EXTRA_FLAGS=--max_allowed_packet=64MB + volumes: + - "matomo_db_data:/bitnami/mariadb" + matomo: + image: docker.io/bitnami/matomo:4 + ports: + - "8080:8080" + environment: + - MATOMO_DATABASE_HOST=mariadb + - MATOMO_DATABASE_PORT_NUMBER=3306 + - MATOMO_DATABASE_USER=bn_matomo + - MATOMO_DATABASE_NAME=bitnami_matomo + - MATOMO_USERNAME=user + - MATOMO_PASSWORD=password + - ALLOW_EMPTY_PASSWORD=yes + volumes: + - "matomo_data:/bitnami/matomo" + depends_on: + - mariadb +volumes: + matomo_db_data: + driver: local + matomo_data: + driver: local diff --git a/package.json b/package.json index d4c1917f86..f85dcfc6f5 100644 --- a/package.json +++ b/package.json @@ -242,7 +242,8 @@ "inquirer": "7.3.3", "json-bigint": "1.0.0", "lodash": "4.17.21", - "lodash-es": "4.17.21", + "lodash-es": "4.17.15", + "matomo-tracker": "2.2.4", "mime-types": "2.1.27", "mkdirp": "1.0.4", "mobx": "5.15.7", diff --git a/source/main/environment.ts b/source/main/environment.ts index 6720986a82..5118eddfee 100644 --- a/source/main/environment.ts +++ b/source/main/environment.ts @@ -50,7 +50,7 @@ const isPreview = checkIsPreview(NETWORK); const isShelleyQA = checkIsShelleyQA(NETWORK); const isSelfnode = checkIsSelfnode(NETWORK); const isDevelopment = checkIsDevelopment(NETWORK); -const analyticsFeatureEnabled = isMainnet || isStaging || isTestnet; +const analyticsFeatureEnabled = true; const keepLocalClusterRunning = process.env.KEEP_LOCAL_CLUSTER_RUNNING; const API_VERSION = process.env.API_VERSION || 'dev'; const NODE_VERSION = '1.35.3'; // TODO: pick up this value from process.env diff --git a/source/main/ipc/electronStoreConversation.ts b/source/main/ipc/electronStoreConversation.ts index f35dd075d1..bbed1f68f3 100644 --- a/source/main/ipc/electronStoreConversation.ts +++ b/source/main/ipc/electronStoreConversation.ts @@ -25,29 +25,7 @@ const unset = async (key: StorageKey) => }); const reset = async () => { - await unset(keys.APP_AUTOMATIC_UPDATE_FAILED); - await unset(keys.APP_UPDATE_COMPLETED); - await unset(keys.CURRENCY_ACTIVE); - await unset(keys.CURRENCY_SELECTED); - await unset(keys.DATA_LAYER_MIGRATION_ACCEPTANCE); - await unset(keys.DISCREET_MODE_ENABLED); - await unset(keys.DOWNLOAD_MANAGER); - await unset(keys.HARDWARE_WALLET_DEVICES); - await unset(keys.HARDWARE_WALLETS); - await unset(keys.READ_NEWS); - await unset(keys.SMASH_SERVER); - await unset(keys.STAKING_INFO_WAS_OPEN); - await unset(keys.STAKE_POOLS_LIST_VIEW_TOOLTIP); - await unset(keys.TERMS_OF_USE_ACCEPTANCE); - await unset(keys.THEME); - await unset(keys.USER_DATE_FORMAT_ENGLISH); - await unset(keys.USER_DATE_FORMAT_JAPANESE); - await unset(keys.USER_LOCALE); - await unset(keys.USER_NUMBER_FORMAT); - await unset(keys.USER_TIME_FORMAT); - await unset(keys.WALLET_MIGRATION_STATUS); - await unset(keys.WALLETS); - await unset(keys.WINDOW_BOUNDS); + await Promise.all(Object.values(keys).map(unset)); }; export const requestElectronStore = (request: ElectronStoreMessage) => { diff --git a/source/renderer/app/App.tsx b/source/renderer/app/App.tsx index 04b87ad789..718fce2b5d 100755 --- a/source/renderer/app/App.tsx +++ b/source/renderer/app/App.tsx @@ -22,6 +22,8 @@ import NewsFeedContainer from './containers/news/NewsFeedContainer'; import ToggleRTSFlagsDialogContainer from './containers/knownIssues/ToggleRTSFlagsDialogContainer'; import RTSFlagsRecommendationOverlayContainer from './containers/knownIssues/RTSFlagsRecommendationOverlayContainer'; import { MenuUpdater } from './containers/MenuUpdater'; +import { AnalyticsProvider } from './components/analytics'; +import { AnalyticsTracker } from './analytics'; @observer class App extends Component<{ diff --git a/source/renderer/app/Routes.tsx b/source/renderer/app/Routes.tsx index 75ce499e84..7f6129b635 100644 --- a/source/renderer/app/Routes.tsx +++ b/source/renderer/app/Routes.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Switch, Route, Redirect, withRouter } from 'react-router-dom'; +import { Redirect, Route, Switch, withRouter } from 'react-router-dom'; import { ROUTES } from './routes-config'; // PAGES import Root from './containers/Root'; @@ -35,6 +35,7 @@ import WalletUtxoPage from './containers/wallet/WalletUtxoPage'; import VotingRegistrationPage from './containers/voting/VotingRegistrationPage'; import { IS_STAKING_INFO_PAGE_AVAILABLE } from './config/stakingConfig'; import AnalyticsConsentPage from './containers/profile/AnalyticsConsentPage'; +import TrackedRoute from './analytics/TrackedRoute'; export const Routes = withRouter(() => ( @@ -54,11 +55,16 @@ export const Routes = withRouter(() => ( path={ROUTES.PROFILE.ANALYTICS} component={AnalyticsConsentPage} /> - - + ( path={ROUTES.WALLETS.ROOT} component={() => } /> - - - + - - + - - + @@ -94,31 +116,38 @@ export const Routes = withRouter(() => ( path={ROUTES.SETTINGS.ROOT} component={() => } /> - - - - - - - @@ -137,33 +166,47 @@ export const Routes = withRouter(() => ( )} /> - - - - - + {IS_STAKING_INFO_PAGE_AVAILABLE && ( - + )} - - diff --git a/source/renderer/app/actions/wallets-actions.ts b/source/renderer/app/actions/wallets-actions.ts index ed4149038b..22fac530c8 100644 --- a/source/renderer/app/actions/wallets-actions.ts +++ b/source/renderer/app/actions/wallets-actions.ts @@ -7,6 +7,7 @@ import type { import type { CsvFileContent } from '../../../common/types/csv-request.types'; import type { QuitStakePoolRequest } from '../api/staking/types'; import type { AssetToken } from '../api/assets/types'; +import Wallet from '../domains/Wallet'; export type WalletImportFromFileParams = { filePath: string; @@ -76,6 +77,7 @@ export default class WalletsActions { note: string; address: string; filePath: string; + wallet: Wallet; }> = new Action(); generateAddressPDFSuccess: Action<{ walletAddress: string; @@ -83,6 +85,7 @@ export default class WalletsActions { saveQRCodeImage: Action<{ address: string; filePath: string; + wallet: Wallet; }> = new Action(); saveQRCodeImageSuccess: Action<{ walletAddress: string; diff --git a/source/renderer/app/analytics/MatomoAnalyticsTracker.ts b/source/renderer/app/analytics/MatomoAnalyticsTracker.ts new file mode 100644 index 0000000000..f788175cea --- /dev/null +++ b/source/renderer/app/analytics/MatomoAnalyticsTracker.ts @@ -0,0 +1,47 @@ +import { AnalyticsAcceptanceStatus, AnalyticsTracker } from '.'; +import { AnalyticsClient } from './types'; +import { Environment } from '../../../common/types/environment.types'; +import LocalStorageApi from '../api/utils/localStorage'; +import { MatomoClient } from './MatomoClient'; +import { NoopAnalyticsClient } from './noopAnalyticsClient'; + +export class MatomoAnalyticsTracker implements AnalyticsTracker { + #analyticsClient: AnalyticsClient; + + constructor( + private environment: Environment, + private localStorageApi: LocalStorageApi + ) { + this.#analyticsClient = NoopAnalyticsClient; + this.#enableTrackingIfAccepted(); + } + + async enableTracking() { + this.#analyticsClient = new MatomoClient( + this.environment, + await this.localStorageApi.getUserID() + ); + } + + disableTracking() { + this.#analyticsClient = NoopAnalyticsClient; + } + + sendPageNavigationEvent(pageTitle: string) { + return this.#analyticsClient.sendPageNavigationEvent(pageTitle); + } + + sendEvent(category: string, name: string, action?: string) { + return this.#analyticsClient.sendEvent(category, name, action); + } + + async #enableTrackingIfAccepted() { + const analyticsAccepted = + (await this.localStorageApi.getAnalyticsAcceptance()) === + AnalyticsAcceptanceStatus.ACCEPTED; + + if (this.environment.analyticsFeatureEnabled && analyticsAccepted) { + this.enableTracking(); + } + } +} diff --git a/source/renderer/app/analytics/MatomoClient.ts b/source/renderer/app/analytics/MatomoClient.ts new file mode 100644 index 0000000000..50f95a7435 --- /dev/null +++ b/source/renderer/app/analytics/MatomoClient.ts @@ -0,0 +1,68 @@ +import MatomoTracker from 'matomo-tracker'; +import { AnalyticsClient } from './types'; +import { Environment } from '../../../common/types/environment.types'; +import formatCpuInfo from '../utils/formatCpuInfo'; +import { + ANALYTICS_API_ENDPOINT, + CPU_DIMENSION_KEY, + DEV_MODE_SITE_MAP_ID, + NETWORK_TO_ANALYTICS_SITE_ID_MAP, + OS_DIMENSION_KEY, + RAM_DIMENSION_KEY, + VERSION_DIMENSION_KEY, +} from '../config/analyticsConfig'; +import { formattedBytesToSize } from '../utils/formatters'; + +/** + * Matomo API reference: + * https://developer.matomo.org/api-reference/tracking-api + */ +export class MatomoClient implements AnalyticsClient { + private matomoTracker: MatomoTracker; + + constructor(private environment: Environment, private userId: string) { + this.matomoTracker = new MatomoTracker( + this.getMatomoSiteId(environment), + ANALYTICS_API_ENDPOINT + ); + } + + sendPageNavigationEvent = async (pageTitle: string) => { + this.matomoTracker.track({ + _id: this.userId, + action_name: pageTitle, + url: this.getAnalyticsURL(), + [CPU_DIMENSION_KEY]: formatCpuInfo(this.environment.cpu), + [RAM_DIMENSION_KEY]: formattedBytesToSize(this.environment.ram), + [OS_DIMENSION_KEY]: this.environment.os, + [VERSION_DIMENSION_KEY]: this.environment.version, + }); + }; + + sendEvent = async ( + category: string, + action: string, + name?: string + ): Promise => { + this.matomoTracker.track({ + _id: this.userId, + ca: 1, + e_c: category, + e_a: action, + e_n: name, + url: this.getAnalyticsURL(), + }); + }; + + private getAnalyticsURL() { + return `http://daedalus/${window.location.hash.replace('#/', '')}`; + } + + private getMatomoSiteId(environment: Environment) { + if (environment.isDev) { + return DEV_MODE_SITE_MAP_ID; + } + + return NETWORK_TO_ANALYTICS_SITE_ID_MAP[environment.network]; + } +} diff --git a/source/renderer/app/analytics/NoopAnalyticsTracker.ts b/source/renderer/app/analytics/NoopAnalyticsTracker.ts new file mode 100644 index 0000000000..f9daa5e01d --- /dev/null +++ b/source/renderer/app/analytics/NoopAnalyticsTracker.ts @@ -0,0 +1,10 @@ +import { AnalyticsTracker } from '.'; + +class NoopAnalyticsTracker implements AnalyticsTracker { + async enableTracking() {} + disableTracking() {} + sendPageNavigationEvent(pageTitle: string) {} + sendEvent(category: string, name: string) {} +} + +export const noopAnalyticsTracker = new NoopAnalyticsTracker(); diff --git a/source/renderer/app/analytics/TrackedRoute.tsx b/source/renderer/app/analytics/TrackedRoute.tsx new file mode 100644 index 0000000000..8696223007 --- /dev/null +++ b/source/renderer/app/analytics/TrackedRoute.tsx @@ -0,0 +1,24 @@ +import React, { FC, useEffect } from 'react'; +import { matchPath, Route, RouteProps } from 'react-router'; +import { useAnalytics } from '../components/analytics'; + +export type TrackedRouteProps = RouteProps & { + pageTitle: string; +}; + +function TrackedRoute(props: TrackedRouteProps) { + const analytics = useAnalytics(); + const { pageTitle, ...restProps } = props; + + useEffect(() => { + const match = matchPath(window.location.hash.replace('#', ''), props); + + if (match !== null) { + analytics.sendPageNavigationEvent(pageTitle); + } + }, [window.location.hash, props]); + + return ; +} + +export default TrackedRoute; diff --git a/source/renderer/app/analytics/index.ts b/source/renderer/app/analytics/index.ts index 328c7daa03..903e452e9f 100644 --- a/source/renderer/app/analytics/index.ts +++ b/source/renderer/app/analytics/index.ts @@ -1 +1,3 @@ -export { AnalyticsAcceptanceStatus } from './types'; +export { AnalyticsAcceptanceStatus, EventCategories } from './types'; +export type { AnalyticsClient, AnalyticsTracker } from './types'; +export { noopAnalyticsTracker } from './NoopAnalyticsTracker'; diff --git a/source/renderer/app/analytics/noopAnalyticsClient.ts b/source/renderer/app/analytics/noopAnalyticsClient.ts new file mode 100644 index 0000000000..6348c62cd4 --- /dev/null +++ b/source/renderer/app/analytics/noopAnalyticsClient.ts @@ -0,0 +1,8 @@ +import { AnalyticsClient } from './types'; + +const NoopAnalyticsClient: AnalyticsClient = { + sendPageNavigationEvent: () => Promise.resolve(), + sendEvent: () => Promise.resolve(), +}; + +export { NoopAnalyticsClient }; diff --git a/source/renderer/app/analytics/types.ts b/source/renderer/app/analytics/types.ts index f67185663c..56737d3a6b 100644 --- a/source/renderer/app/analytics/types.ts +++ b/source/renderer/app/analytics/types.ts @@ -1,5 +1,26 @@ +export interface AnalyticsClient { + sendPageNavigationEvent(pageTitle: string): Promise; + sendEvent(category: string, action: string, name?: string): Promise; +} + export enum AnalyticsAcceptanceStatus { PENDING = 'PENDING', ACCEPTED = 'ACCEPTED', REJECTED = 'REJECTED', } + +export interface AnalyticsTracker { + enableTracking(): Promise; + disableTracking(): void; + sendPageNavigationEvent(pageTitle: string): void; + sendEvent(category: EventCategories, name: string, action?: string): void; +} + +export enum EventCategories { + WALLETS = 'Wallets', + STAKE_POOLS = 'Stake Pools', + SETTINGS = 'Settings', + LAYOUT = 'Layout', + SYSTEM_MENU = 'System Menu', + VOTING = 'Voting', +} diff --git a/source/renderer/app/analytics/utils/getEventNameFromWallet.ts b/source/renderer/app/analytics/utils/getEventNameFromWallet.ts new file mode 100644 index 0000000000..7518328fed --- /dev/null +++ b/source/renderer/app/analytics/utils/getEventNameFromWallet.ts @@ -0,0 +1,4 @@ +import Wallet from '../../domains/Wallet'; + +export const getEventNameFromWallet = (wallet: Wallet) => + wallet.isHardwareWallet ? 'Hardware wallet' : 'Software wallet'; diff --git a/source/renderer/app/api/index.ts b/source/renderer/app/api/index.ts index ca98f86621..1f54636431 100644 --- a/source/renderer/app/api/index.ts +++ b/source/renderer/app/api/index.ts @@ -6,6 +6,7 @@ export type Api = { localStorage: LocalStorageApi; setFaultyNodeSettingsApi?: boolean; }; + export const setupApi = (isTest: boolean): Api => ({ ada: new AdaApi(isTest, { hostname: 'localhost', diff --git a/source/renderer/app/components/analytics/AnalyticsContext.tsx b/source/renderer/app/components/analytics/AnalyticsContext.tsx new file mode 100644 index 0000000000..b22d080b85 --- /dev/null +++ b/source/renderer/app/components/analytics/AnalyticsContext.tsx @@ -0,0 +1,4 @@ +import React from 'react'; +import { AnalyticsTracker } from '../../analytics'; + +export const AnalyticsContext = React.createContext(null); diff --git a/source/renderer/app/components/analytics/AnalyticsProvider.tsx b/source/renderer/app/components/analytics/AnalyticsProvider.tsx new file mode 100644 index 0000000000..89e22e336c --- /dev/null +++ b/source/renderer/app/components/analytics/AnalyticsProvider.tsx @@ -0,0 +1,18 @@ +import React, { FC } from 'react'; +import { AnalyticsContext } from './AnalyticsContext'; +import { AnalyticsTracker } from '../../analytics'; + +interface AnalyticsProviderProps { + children: React.ReactNode; + tracker: AnalyticsTracker; +} + +function AnalyticsProvider({ children, tracker }: AnalyticsProviderProps) { + return ( + + {children} + + ); +} + +export { AnalyticsProvider }; diff --git a/source/renderer/app/components/analytics/index.ts b/source/renderer/app/components/analytics/index.ts new file mode 100644 index 0000000000..7f317c2d5b --- /dev/null +++ b/source/renderer/app/components/analytics/index.ts @@ -0,0 +1,2 @@ +export { AnalyticsProvider } from './AnalyticsProvider'; +export { useAnalytics } from './useAnalytics'; diff --git a/source/renderer/app/components/analytics/useAnalytics.ts b/source/renderer/app/components/analytics/useAnalytics.ts new file mode 100644 index 0000000000..caefabad39 --- /dev/null +++ b/source/renderer/app/components/analytics/useAnalytics.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { AnalyticsContext } from './AnalyticsContext'; + +export const useAnalytics = () => useContext(AnalyticsContext); diff --git a/source/renderer/app/components/analytics/withAnalytics.tsx b/source/renderer/app/components/analytics/withAnalytics.tsx new file mode 100644 index 0000000000..6115bebd4c --- /dev/null +++ b/source/renderer/app/components/analytics/withAnalytics.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { AnalyticsTracker } from '../../analytics'; +import { useAnalytics } from './useAnalytics'; + +export interface WithAnalyticsTrackerProps { + analyticsTracker: AnalyticsTracker; +} + +export function withAnalytics< + T extends WithAnalyticsTrackerProps = WithAnalyticsTrackerProps +>(WrappedComponent: React.ComponentType) { + const displayName = + WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + function ComponentWithTheme(props: Omit) { + const analyticsTracker = useAnalytics(); + + // props comes afterwards so the can override the default ones. + return ( + + ); + } + + ComponentWithTheme.displayName = `withAnalytics(${displayName})`; + + return ComponentWithTheme; +} diff --git a/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.messages.ts b/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.messages.ts index 0b90e08a8d..91627ff990 100644 --- a/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.messages.ts +++ b/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.messages.ts @@ -22,8 +22,8 @@ export const messages = defineMessages({ defaultMessage: '!!!Allow', description: 'Analytics data collection allow button text', }, - skipButton: { - id: 'analytics.dialog.skipButton', + disallowButton: { + id: 'analytics.form.disallowButton', defaultMessage: '!!!Skip', description: 'Analytics data collection skip button text', }, diff --git a/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.scss b/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.scss index fc43c7feef..84c990cd88 100644 --- a/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.scss +++ b/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.scss @@ -6,6 +6,7 @@ height: 100%; justify-content: center; line-height: 1.38; + margin: 40px 0; } .centeredBox { @@ -42,7 +43,7 @@ margin-top: 24px; } -.skipButton { +.disallowButton { margin-right: 12px; } diff --git a/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.tsx b/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.tsx index 53b8ae8d69..41bca62ca2 100644 --- a/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.tsx +++ b/source/renderer/app/components/profile/analytics/AnalyticsConsentForm.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { FormattedMessage, injectIntl } from 'react-intl'; import { Button } from 'react-polymorph/lib/components/Button'; import { Link } from 'react-polymorph/lib/components/Link'; @@ -8,6 +8,7 @@ import styles from './AnalyticsConsentForm.scss'; import { Intl } from '../../../types/i18nTypes'; import { messages } from './AnalyticsConsentForm.messages'; import { CollectedDataOverview } from './CollectedDataOverview'; +import { PRIVACY_POLICY_LINK } from '../../../config/analyticsConfig'; interface AnalyticsConsentFormProps { intl: Intl; @@ -32,11 +33,7 @@ function AnalyticsConsentForm({ const privacyPolicyLink = ( - onExternalLinkClick( - 'https://static.iohk.io/terms/iog-privacy-policy.pdf' - ) - } + onClick={() => onExternalLinkClick(PRIVACY_POLICY_LINK)} label={intl.formatMessage(messages.privacyPolicyLink)} hasIconAfter={false} /> @@ -60,8 +57,8 @@ function AnalyticsConsentForm({

)} @@ -1373,6 +1380,10 @@ class WalletSendForm extends Component { onAdd={(checked) => { onTokenPickerDialogClose(); checked.forEach(this.addAssetRow); + this.props.analyticsTracker.sendEvent( + EventCategories.WALLETS, + 'Added token to transaction' + ); }} /> )} diff --git a/source/renderer/app/components/wallet/send-form/AssetInput.tsx b/source/renderer/app/components/wallet/send-form/AssetInput.tsx index 6dde6ff1fd..ae2541d470 100644 --- a/source/renderer/app/components/wallet/send-form/AssetInput.tsx +++ b/source/renderer/app/components/wallet/send-form/AssetInput.tsx @@ -11,7 +11,7 @@ import removeIcon from '../../../assets/images/remove.inline.svg'; import type { NumberFormat } from '../../../../../common/types/number.types'; import { DiscreetTokenWalletAmount } from '../../../features/discreet-mode'; import Asset from '../../assets/Asset'; -import { Divider } from '../widgets/Divider'; +import { VerticalSeparator } from '../widgets/VerticalSeparator'; import { ClearButton } from '../widgets/ClearButton'; import styles from './AssetInput.scss'; import messages from './messages'; @@ -152,7 +152,7 @@ class AssetInput extends Component { )} {ticker ? ( <> - + {ticker} ) : null} diff --git a/source/renderer/app/components/wallet/transactions/WalletTransactionsList.tsx b/source/renderer/app/components/wallet/transactions/WalletTransactionsList.tsx index 37b3ea9c90..c9c2862988 100644 --- a/source/renderer/app/components/wallet/transactions/WalletTransactionsList.tsx +++ b/source/renderer/app/components/wallet/transactions/WalletTransactionsList.tsx @@ -16,6 +16,7 @@ import { SimpleTransactionList } from './render-strategies/SimpleTransactionList import { TransactionInfo, TransactionsGroup } from './types'; import type { Row } from './types'; import { getNonZeroAssetTokens } from '../../../utils/assets'; +import { AnalyticsTracker, EventCategories } from '../../../analytics'; const messages = defineMessages({ today: { @@ -69,6 +70,7 @@ type Props = { getAsset: (...args: Array) => any; isInternalAddress: (...args: Array) => any; onCopyAssetParam: (...args: Array) => any; + analyticsTracker: AnalyticsTracker; }; type State = { isPreloading: boolean; @@ -203,6 +205,10 @@ class WalletTransactionsList extends Component { onShowMoreTransactions = (walletId: string) => { if (this.props.onShowMoreTransactions) { this.props.onShowMoreTransactions(walletId); + this.props.analyticsTracker.sendEvent( + EventCategories.WALLETS, + 'Clicked Show More Transactions button' + ); } }; getExpandedTransactions = () => this.expandedTransactionIds; diff --git a/source/renderer/app/components/wallet/widgets/Divider.tsx b/source/renderer/app/components/wallet/widgets/VerticalSeparator.tsx similarity index 75% rename from source/renderer/app/components/wallet/widgets/Divider.tsx rename to source/renderer/app/components/wallet/widgets/VerticalSeparator.tsx index dee00283b9..d79a795011 100644 --- a/source/renderer/app/components/wallet/widgets/Divider.tsx +++ b/source/renderer/app/components/wallet/widgets/VerticalSeparator.tsx @@ -2,6 +2,6 @@ import React from 'react'; import styles from './Divider.scss'; -export function Divider() { +export function VerticalSeparator() { return ; } diff --git a/source/renderer/app/config/analyticsConfig.ts b/source/renderer/app/config/analyticsConfig.ts index 9fbfef09db..cd163cddaa 100644 --- a/source/renderer/app/config/analyticsConfig.ts +++ b/source/renderer/app/config/analyticsConfig.ts @@ -1,17 +1,28 @@ import { Network } from '../../../common/types/environment.types'; -export const ANALYTICS_API_ENDPOINT = 'https://mazurek.matomo.cloud/matomo.php'; -export const DEV_MODE_SITE_MAP_ID = 5; +export const ANALYTICS_API_ENDPOINT = 'https://matomo.cw.iog.io/matomo.php'; +export const PRIVACY_POLICY_LINK = + 'https://static.iohk.io/terms/iog-privacy-policy.pdf'; + +// ID used when Daedalus is launched from nix-shell +export const DEV_MODE_SITE_MAP_ID = 11; + +// IDs used when Daedalus is launched as a binary (installed with installer) export const NETWORK_TO_ANALYTICS_SITE_ID_MAP: Record = { - mainnet: 4, - mainnet_flight: 4, + mainnet: 2, + mainnet_flight: 12, testnet: 3, - preprod: 3, - preview: 3, - staging: 5, - shelley_qa: 5, - alonzo_purple: 5, - selfnode: 5, - development: 5, - vasil_dev: 5, + preprod: 9, + preview: 10, + staging: 4, + shelley_qa: 6, + alonzo_purple: 7, + selfnode: 11, + development: 11, + vasil_dev: 8, }; + +export const CPU_DIMENSION_KEY = 'dimension1'; +export const RAM_DIMENSION_KEY = 'dimension2'; +export const OS_DIMENSION_KEY = 'dimension3'; +export const VERSION_DIMENSION_KEY = 'dimension4'; diff --git a/source/renderer/app/containers/MenuUpdater/useMenuUpdater.ts b/source/renderer/app/containers/MenuUpdater/useMenuUpdater.ts index ef6957e23e..a701e81979 100644 --- a/source/renderer/app/containers/MenuUpdater/useMenuUpdater.ts +++ b/source/renderer/app/containers/MenuUpdater/useMenuUpdater.ts @@ -3,6 +3,7 @@ import { matchPath } from 'react-router-dom'; import { WalletSettingsStateEnum } from '../../../../common/ipc/api'; import { ROUTES } from '../../routes-config'; import type { UseMenuUpdaterArgs } from './types'; +import { AnalyticsAcceptanceStatus } from '../../analytics'; const walletRoutes = Object.values(ROUTES.WALLETS); @@ -32,7 +33,9 @@ const useMenuUpdater = ({ } rebuildApplicationMenu.send({ - isNavigationEnabled: profile.areTermsOfUseAccepted, + isNavigationEnabled: + profile.areTermsOfUseAccepted && + profile.analyticsAcceptanceStatus !== AnalyticsAcceptanceStatus.PENDING, walletSettingsState, }); }, [ diff --git a/source/renderer/app/containers/settings/categories/SupportSettingsPage.tsx b/source/renderer/app/containers/settings/categories/SupportSettingsPage.tsx index 134b374fee..1742b3842d 100644 --- a/source/renderer/app/containers/settings/categories/SupportSettingsPage.tsx +++ b/source/renderer/app/containers/settings/categories/SupportSettingsPage.tsx @@ -5,6 +5,7 @@ import SupportSettings from '../../../components/settings/categories/SupportSett import { generateSupportRequestLink } from '../../../../../common/utils/reporting'; import type { InjectedProps } from '../../../types/injectedPropsType'; import { AnalyticsAcceptanceStatus } from '../../../analytics'; +import { ROUTES } from '../../../routes-config'; const messages = defineMessages({ supportRequestLinkUrl: { @@ -28,9 +29,9 @@ class SupportSettingsPage extends Component { }; handleChangeAnalyticsSettings = () => { - this.props.actions.profile.acceptAnalytics.trigger( - AnalyticsAcceptanceStatus.PENDING - ); + this.props.actions.router.goToRoute.trigger({ + route: ROUTES.PROFILE.ANALYTICS, + }); }; handleSupportRequestClick = async ( diff --git a/source/renderer/app/containers/staking/StakePoolsListPage.tsx b/source/renderer/app/containers/staking/StakePoolsListPage.tsx index 021da8f412..2ac5d09dc5 100644 --- a/source/renderer/app/containers/staking/StakePoolsListPage.tsx +++ b/source/renderer/app/containers/staking/StakePoolsListPage.tsx @@ -6,8 +6,12 @@ import DelegationSetupWizardDialogContainer from './dialogs/DelegationSetupWizar import DelegationSetupWizardDialog from '../../components/staking/delegation-setup-wizard/DelegationSetupWizardDialog'; import { ROUTES } from '../../routes-config'; import type { InjectedProps } from '../../types/injectedPropsType'; +import { + withAnalytics, + WithAnalyticsTrackerProps, +} from '../../components/analytics/withAnalytics'; -type Props = InjectedProps; +type Props = InjectedProps & WithAnalyticsTrackerProps; @inject('stores', 'actions') @observer @@ -55,6 +59,7 @@ class StakePoolsListPage extends Component { networkStatus, profile, wallets, + analytics, } = this.props.stores; const { currentTheme, currentLocale } = profile; const { isSynced } = networkStatus; @@ -77,6 +82,7 @@ class StakePoolsListPage extends Component { return ( { } } -export default StakePoolsListPage; +export default withAnalytics(StakePoolsListPage); diff --git a/source/renderer/app/containers/wallet/WalletReceivePage.tsx b/source/renderer/app/containers/wallet/WalletReceivePage.tsx index b518de6a21..9128b75213 100755 --- a/source/renderer/app/containers/wallet/WalletReceivePage.tsx +++ b/source/renderer/app/containers/wallet/WalletReceivePage.tsx @@ -13,6 +13,7 @@ import { generateFileNameWithTimestamp } from '../../../../common/utils/files'; import { ellipsis } from '../../utils/strings'; import { generateSupportRequestLink } from '../../../../common/utils/reporting'; import { WalletLocalData } from '../../types/localDataTypes'; +import Wallet from '../../domains/Wallet'; const messages = defineMessages({ address: { @@ -40,7 +41,7 @@ class WalletReceivePage extends Component { addressToShare: null, }; - get activeWallet() { + get activeWallet(): Wallet { return this.props.stores.wallets.active; } @@ -142,6 +143,7 @@ class WalletReceivePage extends Component { note, address, filePath, + wallet: this.activeWallet, }); }; handleSaveQRCodeImage = async () => { @@ -151,6 +153,7 @@ class WalletReceivePage extends Component { this.props.actions.wallets.saveQRCodeImage.trigger({ address, filePath, + wallet: this.activeWallet, }); }; handleGenerateAddress = (passphrase: string) => { diff --git a/source/renderer/app/containers/wallet/WalletSendPage.tsx b/source/renderer/app/containers/wallet/WalletSendPage.tsx index 87d5802984..f7509247ae 100755 --- a/source/renderer/app/containers/wallet/WalletSendPage.tsx +++ b/source/renderer/app/containers/wallet/WalletSendPage.tsx @@ -15,9 +15,13 @@ import { WALLET_ASSETS_ENABLED } from '../../config/walletsConfig'; import Asset from '../../domains/Asset'; import type { ApiTokens } from '../../api/assets/types'; import { getNonZeroAssetTokens } from '../../utils/assets'; +import { + withAnalytics, + WithAnalyticsTrackerProps, +} from '../../components/analytics/withAnalytics'; import { CoinSelectionsResponse } from '../../api/transactions/types'; -type Props = InjectedProps; +type Props = InjectedProps & WithAnalyticsTrackerProps; type State = { confirmationDialogData: ConfirmationDialogData; }; @@ -186,10 +190,11 @@ class WalletSendPage extends Component { walletName={walletName} onTokenPickerDialogOpen={this.openTokenPickerDialog} onTokenPickerDialogClose={this.closeTokenPickerDialog} + analyticsTracker={this.props.analyticsTracker} confirmationDialogData={this.state.confirmationDialogData} /> ); } } -export default WalletSendPage; +export default withAnalytics(WalletSendPage); diff --git a/source/renderer/app/containers/wallet/WalletSummaryPage.tsx b/source/renderer/app/containers/wallet/WalletSummaryPage.tsx index 5e1642812d..5dd71ccf91 100755 --- a/source/renderer/app/containers/wallet/WalletSummaryPage.tsx +++ b/source/renderer/app/containers/wallet/WalletSummaryPage.tsx @@ -14,6 +14,10 @@ import { formattedWalletAmount } from '../../utils/formatters'; import { getNetworkExplorerUrlByType } from '../../utils/network'; import { WALLET_ASSETS_ENABLED } from '../../config/walletsConfig'; import { getAssetTokens, sortAssets } from '../../utils/assets'; +import { + withAnalytics, + WithAnalyticsTrackerProps, +} from '../../components/analytics/withAnalytics'; import type { InjectedProps } from '../../types/injectedPropsType'; export const messages = defineMessages({ @@ -24,7 +28,7 @@ export const messages = defineMessages({ 'Message shown when wallet has no transactions on wallet summary page.', }, }); -type Props = InjectedProps; +type Props = InjectedProps & WithAnalyticsTrackerProps; type OpenAssetSettingsDialogArgs = { asset: AssetToken; }; @@ -162,6 +166,7 @@ class WalletSummaryPage extends Component { hasAssetsEnabled={hasAssetsEnabled} getAsset={getAsset} onCopyAssetParam={onCopyAssetParam.trigger} + analyticsTracker={this.props.analyticsTracker} /> ); } else if (!hasAny) { @@ -203,4 +208,4 @@ class WalletSummaryPage extends Component { } } -export default WalletSummaryPage; +export default withAnalytics(WalletSummaryPage); diff --git a/source/renderer/app/features/discreet-mode/context.tsx b/source/renderer/app/features/discreet-mode/context.tsx index ad5cd5c240..752686fe4f 100644 --- a/source/renderer/app/features/discreet-mode/context.tsx +++ b/source/renderer/app/features/discreet-mode/context.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; // @ts-ignore ts-migrate(2305) FIXME: Module '"react"' has no exported member 'Node'. import type { Node } from 'react'; import { merge } from 'lodash/fp'; @@ -9,6 +9,7 @@ import { import { useLocalStorageFeature } from '../local-storage'; import { DiscreetMode } from './feature'; import { DiscreetModeApi } from './api'; +import { useAnalytics } from '../../components/analytics'; export const discreetModeContext = React.createContext( null @@ -16,19 +17,28 @@ export const discreetModeContext = React.createContext( interface Props { children: Node; } + export function DiscreetModeFeatureProvider({ children }: Props) { const localStorageFeature = useLocalStorageFeature(); + const analyticsTracker = useAnalytics(); + const [discreetModeFeature] = useState(() => { - const feature = new DiscreetMode(new DiscreetModeApi(localStorageFeature)); - // @ts-ignore ts-migrate(2339) FIXME: Property 'daedalus' does not exist on type 'Window... Remove this comment to see the full error message + const feature = new DiscreetMode( + new DiscreetModeApi(localStorageFeature), + analyticsTracker + ); + window.daedalus = merge(window.daedalus, { features: { discreetModeFeature: feature, }, }); + return feature; }); + useFeature(discreetModeFeature); + return ( {children} diff --git a/source/renderer/app/features/discreet-mode/feature.ts b/source/renderer/app/features/discreet-mode/feature.ts index 1f783de586..9d59a72e99 100644 --- a/source/renderer/app/features/discreet-mode/feature.ts +++ b/source/renderer/app/features/discreet-mode/feature.ts @@ -5,13 +5,14 @@ import { DiscreetModeApi } from './api'; import { SENSITIVE_DATA_SYMBOL } from './config'; import { defaultReplacer } from './replacers/defaultReplacer'; import type { ReplacerFn } from './types'; +import { AnalyticsTracker, EventCategories } from '../../analytics'; export class DiscreetMode extends Feature { - api: DiscreetModeApi; - - constructor(api: DiscreetModeApi) { + constructor( + private api: DiscreetModeApi, + private analyticsTracker: AnalyticsTracker + ) { super(); - this.api = api; runInAction(() => { this.getDiscreetModeSettingsRequest = new Request( this.api.getDiscreetModeSettings @@ -50,6 +51,10 @@ export class DiscreetMode extends Feature { @action toggleDiscreetMode = () => { this.isDiscreetMode = !this.isDiscreetMode; + this.analyticsTracker.sendEvent( + EventCategories.SETTINGS, + `Turned ${this.isDiscreetMode ? 'on' : 'off'} discreet mode` + ); }; @action toggleOpenInDiscreetMode = async () => { @@ -59,6 +64,10 @@ export class DiscreetMode extends Feature { runInAction('Update open in discreet mode settings', () => { this.openInDiscreetMode = nextSetting; }); + this.analyticsTracker.sendEvent( + EventCategories.SETTINGS, + `Turned ${nextSetting ? 'on' : 'off'} discreet mode by default` + ); }; discreetValue({ diff --git a/source/renderer/app/features/discreet-mode/integration-tests.spec.tsx b/source/renderer/app/features/discreet-mode/integration-tests.spec.tsx index 77f20d3288..3d7b89e8e2 100644 --- a/source/renderer/app/features/discreet-mode/integration-tests.spec.tsx +++ b/source/renderer/app/features/discreet-mode/integration-tests.spec.tsx @@ -16,6 +16,8 @@ import { withDiscreetMode, } from './ui'; import { DiscreetMode } from './feature'; +import { AnalyticsProvider } from '../../components/analytics'; +import { noopAnalyticsTracker } from '../../analytics'; describe('Discreet Mode feature', () => { afterEach(cleanup); @@ -29,15 +31,17 @@ describe('Discreet Mode feature', () => { }) { return ( - - - <> - + + + + <> + -
{children}
- -
-
+
{children}
+ +
+
+
); } diff --git a/source/renderer/app/i18n/locales/defaultMessages.json b/source/renderer/app/i18n/locales/defaultMessages.json index cdac62b7d3..6e1cd7b2ab 100644 --- a/source/renderer/app/i18n/locales/defaultMessages.json +++ b/source/renderer/app/i18n/locales/defaultMessages.json @@ -2677,15 +2677,20 @@ "id": "settings.support.steps.reportProblem.link" }, { - "defaultMessage": "!!!You have opted in to analytics data collection. You can {changeAnalyticsSettingsLink}.", + "defaultMessage": "!!!You have opted in to analytics data collection. You can {changeAnalyticsSettingsLink}.", "description": "Analytics data collection description when user opted in", "id": "analytics.form.analyticsAcceptedDescription" }, { - "defaultMessage": "!!!You have opted out of analytics data collection. You can {changeAnalyticsSettingsLink}.", + "defaultMessage": "!!!You have opted out of analytics data collection. You can {changeAnalyticsSettingsLink}.", "description": "Analytics data collection description when user opted out ", "id": "analytics.form.analyticsDeclinedDescription" }, + { + "defaultMessage": "!!!You can {changeAnalyticsSettingsLink}.", + "description": "Change analytics settings link", + "id": "analytics.form.changeAnalyticsSettings" + }, { "defaultMessage": "!!!change this setting here", "description": "Change analytics settings link text", @@ -2714,7 +2719,7 @@ { "defaultMessage": "!!!Skip", "description": "Analytics data collection skip button text", - "id": "analytics.dialog.skipButton" + "id": "analytics.form.disallowButton" }, { "defaultMessage": "!!!Daedalus Privacy Policy", diff --git a/source/renderer/app/i18n/locales/en-US.json b/source/renderer/app/i18n/locales/en-US.json index ffa6a90ddf..4bb4bb4ef2 100755 --- a/source/renderer/app/i18n/locales/en-US.json +++ b/source/renderer/app/i18n/locales/en-US.json @@ -3,11 +3,11 @@ "ImageUploadWidget.dropFileHint": "!!!Drop file here", "analytics.dialog.collapseButton": "Collapse details", "analytics.dialog.expandButton": "Expand details", - "analytics.dialog.skipButton": "Skip", "analytics.form.allowButton": "Allow", - "analytics.form.analyticsAcceptedDescription": "You have opted in to analytics data collection. You can {changeAnalyticsSettingsLink}.", - "analytics.form.analyticsDeclinedDescription": "You have opted out of analytics data collection. You can {changeAnalyticsSettingsLink}.", + "analytics.form.analyticsAcceptedDescription": "You have opted in to analytics data collection. ", + "analytics.form.analyticsDeclinedDescription": "You have opted out of analytics data collection. ", "analytics.form.analyticsSectionPrivacyPolicy": "Read more about our privacy practices in the {privacyPolicyLink}.", + "analytics.form.changeAnalyticsSettings": "You can {changeAnalyticsSettingsLink}.", "analytics.form.changeAnalyticsSettingsLink": "change this setting here", "analytics.form.dataCollectionDetailsDeviceInfoText": "Operating system, RAM, and disk space.", "analytics.form.dataCollectionDetailsDeviceInfoTitle": "Device info", @@ -16,6 +16,7 @@ "analytics.form.dataCollectionDetailsUserBehaviourTitle": "User click behavior", "analytics.form.dataCollectionSwitchText": "Allow anonymous data collection", "analytics.form.description": "All data is anonymous and is used only for product development purposes.", + "analytics.form.disallowButton": "Disallow", "analytics.form.privacyPolicyLink": "Daedalus Privacy Policy", "analytics.form.title": "Analytics data collection", "api.errors.ApiMethodNotYetImplementedError": "This API method is not yet implemented.", diff --git a/source/renderer/app/i18n/locales/ja-JP.json b/source/renderer/app/i18n/locales/ja-JP.json index 173dca13a1..56659c05b2 100755 --- a/source/renderer/app/i18n/locales/ja-JP.json +++ b/source/renderer/app/i18n/locales/ja-JP.json @@ -3,11 +3,11 @@ "ImageUploadWidget.dropFileHint": "!!!Drop file here", "analytics.dialog.collapseButton": "詳細を隠す", "analytics.dialog.expandButton": "詳細を表示する", - "analytics.dialog.skipButton": "スキップする", "analytics.form.allowButton": "許可する", - "analytics.form.analyticsAcceptedDescription": "分析データの収集が設定されています。この設定は{changeAnalyticsSettingsLink}できます。", - "analytics.form.analyticsDeclinedDescription": "分析データの収集が解除されています。この設定は{changeAnalyticsSettingsLink}できます。", + "analytics.form.analyticsAcceptedDescription": "分析データの収集が設定されています。", + "analytics.form.analyticsDeclinedDescription": "分析データの収集が解除されています。", "analytics.form.analyticsSectionPrivacyPolicy": "プライバシーに関する詳細は、{privacyPolicyLink}をご覧ください。", + "analytics.form.changeAnalyticsSettings": "この設定は{changeAnalyticsSettingsLink}できます。", "analytics.form.changeAnalyticsSettingsLink": "ここから変更", "analytics.form.dataCollectionDetailsDeviceInfoText": "オペレーティングシステム、RAM、ディスク空き容量", "analytics.form.dataCollectionDetailsDeviceInfoTitle": "デバイス情報", @@ -16,6 +16,7 @@ "analytics.form.dataCollectionDetailsUserBehaviourTitle": "ユーザーのクリック行動", "analytics.form.dataCollectionSwitchText": "匿名データ収集を許可する", "analytics.form.description": "分析データは、製品開発の目的でのみ使用されます。", + "analytics.form.disallowButton": "許可しない", "analytics.form.privacyPolicyLink": "Daedalusプライバシーポリシー", "analytics.form.title": "分析データの収集", "api.errors.ApiMethodNotYetImplementedError": "このAPIはまだ実装されていません。", diff --git a/source/renderer/app/index.tsx b/source/renderer/app/index.tsx index 1dd930ec81..0db1b27654 100755 --- a/source/renderer/app/index.tsx +++ b/source/renderer/app/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { configure, action } from 'mobx'; +import { action, configure } from 'mobx'; import { render } from 'react-dom'; import { addLocaleData } from 'react-intl'; import en from 'react-intl/locale-data/en'; @@ -7,19 +7,20 @@ import ja from 'react-intl/locale-data/ja'; import { createHashHistory } from 'history'; import { RouterStore, syncHistoryWithStore } from 'mobx-react-router'; import App from './App'; -import setupStores from './stores'; +import { setUpStores } from './stores'; import actions from './actions'; import utils from './utils'; import Action from './actions/lib/Action'; import translations from './i18n/translations'; import '!style-loader!css-loader!sass-loader!./themes/index.global.scss'; // eslint-disable-line - import { setupApi } from './api'; import LocalStorageApi from './api/utils/localStorage'; import { DiscreetModeFeatureProvider, LocalStorageFeatureProvider, } from './features'; +import { MatomoAnalyticsTracker } from './analytics/MatomoAnalyticsTracker'; +import { AnalyticsProvider } from './components/analytics'; // run MobX in strict mode configure({ enforceActions: 'always', @@ -34,7 +35,11 @@ const initializeDaedalus = () => { const api = setupApi(isTest); const hashHistory = createHashHistory(); const routingStore = new RouterStore(); - const stores = setupStores(api, actions, routingStore); + const analyticsTracker = new MatomoAnalyticsTracker( + environment, + api.localStorage + ); + const stores = setUpStores(api, actions, routingStore, analyticsTracker); const history = syncHistoryWithStore(hashHistory, routingStore); // @ts-ignore ts-migrate(2339) FIXME: Property 'daedalus' does not exist on type 'Window... Remove this comment to see the full error message window.daedalus = { @@ -46,16 +51,18 @@ const initializeDaedalus = () => { translations, reset: action(() => { Action.resetAllActions(); - setupStores(api, actions, routingStore); + setUpStores(api, actions, routingStore, analyticsTracker); }), }; const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('No #root element found.'); render( - - - + + + + + , rootElement ); diff --git a/source/renderer/app/stores/AppStore.ts b/source/renderer/app/stores/AppStore.ts index 75968db700..04d90bd00a 100644 --- a/source/renderer/app/stores/AppStore.ts +++ b/source/renderer/app/stores/AppStore.ts @@ -15,6 +15,7 @@ import { getGPUStatusChannel } from '../ipc/get-gpu-status.ipc'; import { generateFileNameWithTimestamp } from '../../../common/utils/files'; import type { GpuStatus } from '../types/gpuStatus'; import type { ApplicationDialog } from '../types/applicationDialogTypes'; +import { EventCategories } from '../analytics'; export default class AppStore extends Store { @observable @@ -87,6 +88,10 @@ export default class AppStore extends Store { @action _toggleNewsFeed = () => { this.newsFeedIsOpen = !this.newsFeedIsOpen; + + if (this.newsFeedIsOpen) { + this.analytics.sendEvent(EventCategories.LAYOUT, 'Opened newsfeed'); + } }; @action _closeNewsFeed = () => { @@ -114,29 +119,46 @@ export default class AppStore extends Store { case DIALOGS.ABOUT: // @ts-ignore ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message this._updateActiveDialog(DIALOGS.ABOUT); - + this.analytics.sendEvent( + EventCategories.SYSTEM_MENU, + 'Showed about dialog' + ); break; case DIALOGS.DAEDALUS_DIAGNOSTICS: // @ts-ignore ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message this._updateActiveDialog(DIALOGS.DAEDALUS_DIAGNOSTICS); + this.analytics.sendEvent( + EventCategories.SYSTEM_MENU, + 'Showed diagnostics dialog' + ); break; case DIALOGS.TOGGLE_RTS_FLAGS_MODE: // @ts-ignore ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message this._updateActiveDialog(DIALOGS.TOGGLE_RTS_FLAGS_MODE); - + this.analytics.sendEvent( + EventCategories.SYSTEM_MENU, + 'Showed toggle RTS flags dialog' + ); break; case DIALOGS.ITN_REWARDS_REDEMPTION: // @ts-ignore ts-migrate(2554) FIXME: Expected 1 arguments, but got 0. this.actions.staking.onRedeemStart.trigger(); + this.analytics.sendEvent( + EventCategories.SYSTEM_MENU, + 'Showed ITN rewards redemption dialog' + ); break; case NOTIFICATIONS.DOWNLOAD_LOGS: this._downloadLogs(); - + this.analytics.sendEvent( + EventCategories.SYSTEM_MENU, + 'Downloaded logs' + ); break; case PAGES.SETTINGS: diff --git a/source/renderer/app/stores/AssetsStore.ts b/source/renderer/app/stores/AssetsStore.ts index c8ff22af56..bc23ef3d10 100644 --- a/source/renderer/app/stores/AssetsStore.ts +++ b/source/renderer/app/stores/AssetsStore.ts @@ -6,6 +6,7 @@ import Asset from '../domains/Asset'; import { ROUTES } from '../routes-config'; import { ellipsis } from '../utils/strings'; import type { GetAssetsResponse, AssetToken } from '../api/assets/types'; +import { EventCategories } from '../analytics'; type WalletId = string; export default class AssetsStore extends Store { @@ -107,6 +108,11 @@ export default class AssetsStore extends Store { await this.api.localStorage.setAssetLocalData(policyId, assetName, { decimals, }); + + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Changed native token settings' + ); }; @action _onEditedAssetUnset = () => { @@ -193,6 +199,11 @@ export default class AssetsStore extends Store { ); // @ts-ignore ts-migrate(1320) FIXME: Type of 'await' operand must either be a valid pro... Remove this comment to see the full error message await this.favoritesRequest.execute(); + + this.analytics.sendEvent( + EventCategories.WALLETS, + `${!isFavorite ? 'Added token to' : 'Removed token from'} favorites` + ); }; _retrieveAssetsRequest = (walletId: string): Request => this.assetsRequests[walletId] || this._createWalletTokensRequest(walletId); diff --git a/source/renderer/app/stores/CurrencyStore.ts b/source/renderer/app/stores/CurrencyStore.ts index 8ac7ad688c..cbdaddb31a 100644 --- a/source/renderer/app/stores/CurrencyStore.ts +++ b/source/renderer/app/stores/CurrencyStore.ts @@ -7,6 +7,7 @@ import { getCurrencyFromCode, } from '../config/currencyConfig'; import type { Currency, LocalizedCurrency } from '../types/currencyTypes'; +import { EventCategories } from '../analytics'; export default class CurrencyStore extends Store { @observable @@ -127,10 +128,23 @@ export default class CurrencyStore extends Store { this.getCurrencyRate(); await this.api.localStorage.setCurrencySelected(selected.code); } + + this.analytics.sendEvent( + EventCategories.SETTINGS, + 'Changed currency', + code + ); }; @action _toggleCurrencyIsActive = () => { this.isActive = !this.isActive; this.api.localStorage.setCurrencyIsActive(this.isActive); + + this.analytics.sendEvent( + EventCategories.SETTINGS, + `Turned ${ + this.isActive ? 'on' : 'off' + } displaying ada balances in other currency` + ); }; } diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 56d9985649..fb6dc7cccf 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -97,6 +97,7 @@ import type { TrezorWitness, } from '../../../common/types/hardware-wallets.types'; import { logger } from '../utils/logging'; +import { EventCategories } from '../analytics'; import { HardwareWalletDevicesType, HardwareWalletLocalData, @@ -526,6 +527,12 @@ export default class HardwareWalletsStore extends Store { isVotingRegistrationTransaction, } ); + + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Transaction made', + 'Hardware wallet' + ); } else { this.setTransactionPendingState(false); } @@ -1276,6 +1283,7 @@ export default class HardwareWalletsStore extends Store { this.unfinishedWalletAddressVerification = address; this.hwDeviceStatus = HwDeviceStatuses.CONNECTING; }); + const walletId = get(this.stores.wallets, ['active', 'id']); const hardwareWalletConnectionData = get( this.hardwareWalletsConnectionData, @@ -1473,6 +1481,11 @@ export default class HardwareWalletsStore extends Store { ); this.showAddress(params); } + + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Verified wallet address with hardware wallet' + ); } else { runInAction( 'HardwareWalletsStore:: Address Verified but not correct', diff --git a/source/renderer/app/stores/NetworkStatusStore.ts b/source/renderer/app/stores/NetworkStatusStore.ts index 4f6998e1cd..3c14077ec0 100644 --- a/source/renderer/app/stores/NetworkStatusStore.ts +++ b/source/renderer/app/stores/NetworkStatusStore.ts @@ -42,6 +42,7 @@ import type { CheckDiskSpaceResponse } from '../../../common/types/no-disk-space import { TlsCertificateNotValidError } from '../api/nodes/errors'; import { openLocalDirectoryChannel } from '../ipc/open-local-directory'; import { toggleRTSFlagsModeChannel } from '../ipc/toggleRTSFlagsModeChannel'; +import { EventCategories } from '../analytics'; // DEFINE CONSTANTS ------------------------- const NETWORK_STATUS = { @@ -431,6 +432,10 @@ export default class NetworkStatusStore extends Store { // DEFINE ACTIONS @action _toggleRTSFlagsMode = async () => { + this.analytics.sendEvent( + EventCategories.SETTINGS, + `RTS flags ${this.isRTSFlagsModeEnabled ? 'disabled' : 'enabled'}` + ); await toggleRTSFlagsModeChannel.send(); }; diff --git a/source/renderer/app/stores/ProfileStore.ts b/source/renderer/app/stores/ProfileStore.ts index ea8c53ee06..57c0b5e549 100644 --- a/source/renderer/app/stores/ProfileStore.ts +++ b/source/renderer/app/stores/ProfileStore.ts @@ -1,8 +1,6 @@ -import { action, observable, computed, runInAction } from 'mobx'; +import { action, computed, observable, runInAction } from 'mobx'; import BigNumber from 'bignumber.js'; -import { includes, camelCase } from 'lodash'; -import axios from 'axios'; -import { v4 as uuidv4 } from 'uuid'; +import { camelCase, includes } from 'lodash'; import { toJS } from '../../../common/utils/helper'; import Store from './lib/Store'; import Request from './lib/LocalizedRequest'; @@ -15,35 +13,35 @@ import { logger } from '../utils/logging'; import { setStateSnapshotLogChannel } from '../ipc/setStateSnapshotLogChannel'; import { getDesktopDirectoryPathChannel } from '../ipc/getDesktopDirectoryPathChannel'; import { getSystemLocaleChannel } from '../ipc/getSystemLocaleChannel'; +import type { Locale } from '../../../common/types/locales.types'; import { LOCALES } from '../../../common/types/locales.types'; import { compressLogsChannel, downloadLogsChannel, getLogsChannel, } from '../ipc/logs.ipc'; -import type { LogFiles, CompressedLogStatus } from '../types/LogTypes'; +import type { CompressedLogStatus, LogFiles } from '../types/LogTypes'; import type { StateSnapshotLogParams } from '../../../common/types/logging.types'; -import type { Locale } from '../../../common/types/locales.types'; import { DEFAULT_NUMBER_FORMAT, NUMBER_FORMATS, } from '../../../common/types/number.types'; import { + getRequestKeys, hasLoadedRequest, isRequestSet, requestGetter, requestGetterLocale, - getRequestKeys, } from '../utils/storesUtils'; import { - NUMBER_OPTIONS, DATE_ENGLISH_OPTIONS, DATE_JAPANESE_OPTIONS, - TIME_OPTIONS, + NUMBER_OPTIONS, PROFILE_SETTINGS, + TIME_OPTIONS, } from '../config/profileConfig'; import { buildSystemInfo } from '../utils/buildSystemInfo'; -import { AnalyticsAcceptanceStatus } from '../analytics/types'; +import { AnalyticsAcceptanceStatus, EventCategories } from '../analytics/types'; export default class ProfileStore extends Store { @observable @@ -173,8 +171,7 @@ export default class ProfileStore extends Store { this._updateBigNumberFormat, this._redirectToInitialSettingsIfNoLocaleSet, this._redirectToAnalyticsScreenIfNotConfirmed, - this._redirectToTermsOfUseScreenIfTermsNotAccepted, // this._redirectToDataLayerMigrationScreenIfMigrationHasNotAccepted, - this._redirectToMainUiAfterAnalyticsAreConfirmed, + this._redirectToTermsOfUseScreenIfTermsNotAccepted, this._redirectToMainUiAfterTermsAreAccepted, this._redirectToMainUiAfterDataLayerMigrationIsAccepted, ]); @@ -308,10 +305,7 @@ export default class ProfileStore extends Store { @computed get analyticsAcceptanceStatus(): AnalyticsAcceptanceStatus { - return ( - this.getAnalyticsAcceptanceRequest.result || - AnalyticsAcceptanceStatus.PENDING - ); + return this.getAnalyticsAcceptanceRequest.result; } @computed @@ -371,12 +365,20 @@ export default class ProfileStore extends Store { // @ts-ignore ts-migrate(2339) FIXME: Property 'stores' does not exist on type 'ProfileS... Remove this comment to see the full error message this.stores.wallets.refreshWalletsData(); } + + this.analytics.sendEvent( + EventCategories.SETTINGS, + 'Changed user settings', + param + ); }; _updateTheme = async ({ theme }: { theme: string }) => { // @ts-ignore ts-migrate(1320) FIXME: Type of 'await' operand must either be a valid pro... Remove this comment to see the full error message await this.setThemeRequest.execute(theme); // @ts-ignore ts-migrate(1320) FIXME: Type of 'await' operand must either be a valid pro... Remove this comment to see the full error message await this.getThemeRequest.execute(); + + this.analytics.sendEvent(EventCategories.SETTINGS, 'Changed theme', theme); }; _acceptTermsOfUse = async () => { // @ts-ignore ts-migrate(1320) FIXME: Type of 'await' operand must either be a valid pro... Remove this comment to see the full error message @@ -388,8 +390,24 @@ export default class ProfileStore extends Store { this.getTermsOfUseAcceptanceRequest.execute(); }; _setAnalyticsAcceptanceStatus = (status: AnalyticsAcceptanceStatus) => { + const previousStatus = this.analyticsAcceptanceStatus; + this.setAnalyticsAcceptanceRequest.execute(status); this.getAnalyticsAcceptanceRequest.execute(); + + if (status === AnalyticsAcceptanceStatus.ACCEPTED) { + this.analytics.enableTracking(); + } else if (status === AnalyticsAcceptanceStatus.REJECTED) { + this.analytics.disableTracking(); + } + + if (previousStatus === AnalyticsAcceptanceStatus.PENDING) { + this._redirectToRoot(); + } else { + this.actions.router.goToRoute.trigger({ + route: ROUTES.SETTINGS.SUPPORT, + }); + } }; _getAnalyticsAcceptance = () => { this.getAnalyticsAcceptanceRequest.execute(); @@ -495,14 +513,6 @@ export default class ProfileStore extends Store { this._redirectToRoot(); } }; - _redirectToMainUiAfterAnalyticsAreConfirmed = () => { - if ( - this.analyticsAcceptanceStatus !== AnalyticsAcceptanceStatus.PENDING && - this._isOnAnalyticsPage() - ) { - this._redirectToRoot(); - } - }; _redirectToMainUiAfterDataLayerMigrationIsAccepted = () => { if ( this.isDataLayerMigrationAccepted && diff --git a/source/renderer/app/stores/SidebarStore.spec.ts b/source/renderer/app/stores/SidebarStore.spec.ts index ad1ba96246..718898aedc 100644 --- a/source/renderer/app/stores/SidebarStore.spec.ts +++ b/source/renderer/app/stores/SidebarStore.spec.ts @@ -4,6 +4,7 @@ import type { ActionsMap } from '../actions/index'; import { WalletSortBy, WalletSortOrder } from '../types/sidebarTypes'; import type { SidebarWalletType } from '../types/sidebarTypes'; import SidebarStore from './SidebarStore'; +import { noopAnalyticsTracker } from '../analytics'; describe('Sidebar Store', () => { const api: Api = { @@ -22,7 +23,7 @@ describe('Sidebar Store', () => { isLegacy?: boolean; }>; }) { - const sidebarStore = new SidebarStore(api, actions); + const sidebarStore = new SidebarStore(api, actions, noopAnalyticsTracker); sidebarStore.stores = { wallets: { all: wallets, diff --git a/source/renderer/app/stores/SidebarStore.ts b/source/renderer/app/stores/SidebarStore.ts index 702f8288fd..1310ca29c2 100644 --- a/source/renderer/app/stores/SidebarStore.ts +++ b/source/renderer/app/stores/SidebarStore.ts @@ -1,5 +1,5 @@ import { action, computed, observable } from 'mobx'; -import { get } from 'lodash'; +import { debounce, get } from 'lodash'; import { sidebarConfig } from '../config/sidebarConfig'; import type { SidebarCategoryInfo } from '../config/sidebarConfig'; import type { @@ -10,6 +10,7 @@ import type { import { WalletSortBy, WalletSortOrder } from '../types/sidebarTypes'; import { changeWalletSorting, sortWallets } from '../utils/walletSorting'; import Store from './lib/Store'; +import { EventCategories } from '../analytics'; export default class SidebarStore extends Store { @observable @@ -97,10 +98,16 @@ export default class SidebarStore extends Store { sortOrder: this.walletSortConfig.sortOrder, currentSortBy: this.walletSortConfig.sortBy, }); + + this.analytics.sendEvent( + EventCategories.LAYOUT, + 'Changed wallet sorting settings' + ); }; @action onSearchValueUpdated = (searchValue: string) => { this.searchValue = searchValue; + this._sendSearchAnalyticsEvent(); }; @action _configureCategories = () => { @@ -175,14 +182,17 @@ export default class SidebarStore extends Store { @action _showSubMenus = () => { this.isShowingSubMenus = true; + this.analytics.sendEvent(EventCategories.LAYOUT, 'Toggled submenu'); }; @action _hideSubMenus = () => { this.isShowingSubMenus = false; + this.analytics.sendEvent(EventCategories.LAYOUT, 'Toggled submenu'); }; @action _toggleSubMenus = () => { this.isShowingSubMenus = !this.isShowingSubMenus; + this.analytics.sendEvent(EventCategories.LAYOUT, 'Toggled submenu'); }; _syncSidebarRouteWithRouter = () => { const route = this.stores.app.currentRoute; @@ -199,4 +209,7 @@ export default class SidebarStore extends Store { this._configureCategories(); } }; + _sendSearchAnalyticsEvent = debounce(() => { + this.analytics.sendEvent(EventCategories.LAYOUT, 'Used wallet search'); + }, 5000); } diff --git a/source/renderer/app/stores/StakingStore.ts b/source/renderer/app/stores/StakingStore.ts index 704fa767c6..fb33de3e34 100644 --- a/source/renderer/app/stores/StakingStore.ts +++ b/source/renderer/app/stores/StakingStore.ts @@ -1,7 +1,7 @@ import { computed, action, observable, runInAction } from 'mobx'; import BigNumber from 'bignumber.js'; import path from 'path'; -import { orderBy, find, map, get } from 'lodash'; +import { orderBy, find, map, get, debounce } from 'lodash'; import Store from './lib/Store'; import Request from './lib/LocalizedRequest'; import { ROUTES } from '../routes-config'; @@ -36,6 +36,7 @@ import { showSaveDialogChannel } from '../ipc/show-file-dialog-channels'; import { generateFileNameWithTimestamp } from '../../../common/utils/files'; import type { RedeemItnRewardsStep } from '../types/stakingTypes'; import type { CsvFileContent } from '../../../common/types/csv-request.types'; +import { EventCategories } from '../analytics'; export default class StakingStore extends Store { @observable @@ -227,10 +228,20 @@ export default class StakingStore extends Store { _setSelectedDelegationWalletId = (walletId: string) => { this.selectedDelegationWalletId = walletId; }; + + _sendStakePoolsSliderUsedAnalyticsEvent = debounce(() => { + this.analytics.sendEvent( + EventCategories.STAKE_POOLS, + 'Used stake pools amount slider' + ); + }, 5000); + @action _setStake = (stake: number) => { this.stake = stake; + this._sendStakePoolsSliderUsedAnalyticsEvent(); }; + @action _rankStakePools = () => { this.isRanking = true; @@ -266,6 +277,11 @@ export default class StakingStore extends Store { // Update // @ts-ignore ts-migrate(2339) FIXME: Property 'api' does not exist on type 'StakingStor... Remove this comment to see the full error message await this.api.localStorage.setSmashServer(smashServerUrl); + this.analytics.sendEvent( + EventCategories.SETTINGS, + 'Changed SMASH server', + smashServerUrl + ); } catch (error) { runInAction(() => { this.smashServerUrlError = error; @@ -383,6 +399,13 @@ export default class StakingStore extends Store { setTimeout(() => { this.resetStakePoolTransactionChecker(); }, STAKE_POOL_TRANSACTION_CHECKER_TIMEOUT); + + const wallet = this.stores.wallets.getWalletById(walletId); + + this.analytics.sendEvent( + EventCategories.STAKE_POOLS, + wallet.isDelegating ? 'Redelegated a wallet' : 'Delegated a wallet' + ); } catch (error) { this.resetStakePoolTransactionChecker(); throw error; @@ -507,6 +530,10 @@ export default class StakingStore extends Store { }); // @ts-ignore ts-migrate(2339) FIXME: Property 'actions' does not exist on type 'Staking... Remove this comment to see the full error message this.actions.staking.requestCSVFileSuccess.trigger(); + this.analytics.sendEvent( + EventCategories.STAKE_POOLS, + 'Exported rewards as CSV' + ); }; calculateDelegationFee = async ( delegationFeeRequest: GetDelegationFeeRequest diff --git a/source/renderer/app/stores/TransactionsStore.ts b/source/renderer/app/stores/TransactionsStore.ts index 8f003da4f6..ab480dd3b0 100644 --- a/source/renderer/app/stores/TransactionsStore.ts +++ b/source/renderer/app/stores/TransactionsStore.ts @@ -28,6 +28,7 @@ import { isTransactionInFilterRange, } from '../utils/transaction'; import type { ApiTokens } from '../api/assets/types'; +import { EventCategories } from '../analytics'; const INITIAL_SEARCH_LIMIT = null; // 'null' value stands for 'load all' @@ -367,6 +368,11 @@ export default class TransactionsStore extends Store { ...currentFilterOptions, ...filterOptions, }; + + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Set transaction filters' + ); return true; }; @action @@ -376,6 +382,7 @@ export default class TransactionsStore extends Store { this._filterOptionsForWallets[wallet.id] = { ...emptyTransactionFilterOptions, }; + return true; }; @action @@ -402,8 +409,13 @@ export default class TransactionsStore extends Store { getAsset, isInternalAddress, }); - // @ts-ignore ts-migrate(2554) FIXME: Expected 1 arguments, but got 0. - if (success) actions.transactions.requestCSVFileSuccess.trigger(); + if (success) { + actions.transactions.requestCSVFileSuccess.trigger(); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Exported transactions as CSV' + ); + } }; @action _createExternalTransaction = async (signedTransactionBlob: Buffer) => { diff --git a/source/renderer/app/stores/UiDialogsStore.ts b/source/renderer/app/stores/UiDialogsStore.ts index 5585eef1b0..064cb8dc87 100644 --- a/source/renderer/app/stores/UiDialogsStore.ts +++ b/source/renderer/app/stores/UiDialogsStore.ts @@ -1,6 +1,10 @@ -import { ReactNode } from 'react'; +import { Component, ReactNode } from 'react'; import { observable, action } from 'mobx'; import Store from './lib/Store'; +import WalletReceiveDialog from '../components/wallet/receive/WalletReceiveDialog'; +import AssetSettingsDialog from '../components/assets/AssetSettingsDialog'; +import DelegationSetupWizardDialog from '../components/staking/delegation-setup-wizard/DelegationSetupWizardDialog'; +import { EventCategories } from '../analytics'; export default class UiDialogsStore extends Store { @observable @@ -25,7 +29,7 @@ export default class UiDialogsStore extends Store { countdownSinceDialogOpened = (countDownTo: number) => Math.max(countDownTo - this.secondsSinceActiveDialogIsOpen, 0); @action - _onOpen = ({ dialog }: { dialog: (...args: Array) => any }) => { + _onOpen = ({ dialog }: { dialog: object }) => { this._reset(); this.activeDialog = dialog; @@ -34,6 +38,8 @@ export default class UiDialogsStore extends Store { this.secondsSinceActiveDialogIsOpen = 0; if (this._secondsTimerInterval) clearInterval(this._secondsTimerInterval); this._secondsTimerInterval = setInterval(this._updateSeconds, 1000); + + this._handleAnalytics(dialog); }; @action _onClose = () => { @@ -53,4 +59,31 @@ export default class UiDialogsStore extends Store { this.secondsSinceActiveDialogIsOpen = 0; this.dataForActiveDialog = {}; }; + _handleAnalytics = (dialog: object) => { + switch (dialog) { + case WalletReceiveDialog: + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Opened share wallet address modal' + ); + break; + + case AssetSettingsDialog: + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Opened native token settings' + ); + break; + + case DelegationSetupWizardDialog: + this.analytics.sendEvent( + EventCategories.STAKE_POOLS, + 'Opened delegate wallet dialog' + ); + break; + + default: + break; + } + }; } diff --git a/source/renderer/app/stores/VotingStore.spec.ts b/source/renderer/app/stores/VotingStore.spec.ts index 89d0fb292b..6d049c494a 100644 --- a/source/renderer/app/stores/VotingStore.spec.ts +++ b/source/renderer/app/stores/VotingStore.spec.ts @@ -2,6 +2,7 @@ import type { Api } from '../api/index'; import type { ActionsMap } from '../actions/index'; import VotingStore, { FundPhase } from './VotingStore'; import type { CatalystFund } from '../api/voting/types'; +import { noopAnalyticsTracker } from '../analytics'; const mockFundInfo = { current: { @@ -17,6 +18,7 @@ describe('VotingStore', () => { ada: jest.fn(), } as any; const actions: ActionsMap = jest.fn() as any; + const cases = [ [undefined, null], [ @@ -40,7 +42,7 @@ describe('VotingStore', () => { ], [mockFundInfo.current.resultsTime, FundPhase.RESULTS], ]; - const votingStore = new VotingStore(api, actions); + const votingStore = new VotingStore(api, actions, noopAnalyticsTracker); beforeAll(() => { votingStore.catalystFund = mockFundInfo as CatalystFund; diff --git a/source/renderer/app/stores/VotingStore.ts b/source/renderer/app/stores/VotingStore.ts index fcbb497998..d64ae61c6f 100644 --- a/source/renderer/app/stores/VotingStore.ts +++ b/source/renderer/app/stores/VotingStore.ts @@ -25,6 +25,7 @@ import type { VotingMetadataType, } from '../api/transactions/types'; import type { CatalystFund } from '../api/voting/types'; +import { EventCategories } from '../analytics'; export type VotingRegistrationKeyType = { bytes: (...args: Array) => any; @@ -409,6 +410,7 @@ export default class VotingStore extends Store { this._setQrCode(formattedArrayBufferToHexString(encrypt)); this._nextRegistrationStep(); + this.analytics.sendEvent(EventCategories.VOTING, 'Registered for voting'); }; _saveAsPDF = async () => { const { qrCode, selectedWalletId } = this; diff --git a/source/renderer/app/stores/WalletMigrationStore.ts b/source/renderer/app/stores/WalletMigrationStore.ts index a593f1e5e7..ee07ac5f5a 100644 --- a/source/renderer/app/stores/WalletMigrationStore.ts +++ b/source/renderer/app/stores/WalletMigrationStore.ts @@ -29,6 +29,7 @@ import { import { IMPORT_WALLET_STEPS } from '../config/walletRestoreConfig'; import { IS_AUTOMATIC_WALLET_MIGRATION_ENABLED } from '../config/walletsConfig'; import type { ImportWalletStep } from '../types/walletRestoreTypes'; +import { EventCategories } from '../analytics'; export type WalletMigrationStatus = | 'unstarted' @@ -311,6 +312,11 @@ export default class WalletMigrationStore extends Store { runInAction('update isRestorationRunning', () => { this.isRestorationRunning = false; }); + + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Restored legacy wallet(s)' + ); }; @action _restoreWallet = async (exportedWallet: ExportedByronWallet) => { diff --git a/source/renderer/app/stores/WalletSettingsStore.ts b/source/renderer/app/stores/WalletSettingsStore.ts index 10ab29743b..46ae6116c5 100644 --- a/source/renderer/app/stores/WalletSettingsStore.ts +++ b/source/renderer/app/stores/WalletSettingsStore.ts @@ -10,6 +10,7 @@ import { getRawWalletId } from '../api/utils'; import type { WalletExportToFileParams } from '../actions/wallet-settings-actions'; import type { WalletUtxos } from '../api/wallets/types'; import { RECOVERY_PHRASE_VERIFICATION_STATUSES } from '../config/walletRecoveryPhraseVerificationConfig'; +import { EventCategories } from '../analytics'; import { WalletLocalData } from '../types/localDataTypes'; export default class WalletSettingsStore extends Store { @@ -156,6 +157,11 @@ export default class WalletSettingsStore extends Store { this.actions.dialogs.closeActiveDialog.trigger(); this.updateSpendingPasswordRequest.reset(); this.stores.wallets.refreshWalletsData(); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Changed wallet settings', + 'password' + ); }; @action _updateWalletField = async ({ @@ -189,6 +195,11 @@ export default class WalletSettingsStore extends Store { }); this.updateWalletRequest.reset(); this.stores.wallets.refreshWalletsData(); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Changed wallet settings', + field + ); }; @action _exportToFile = async (params: WalletExportToFileParams) => { @@ -279,6 +290,11 @@ export default class WalletSettingsStore extends Store { const isCorrect = walletId === activeWalletId; const nextStep = isCorrect ? 3 : 4; + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Verified recovery phrase' + ); + if (isCorrect) { const recoveryPhraseVerificationDate = new Date(); await this.actions.walletsLocal.setWalletLocalData.trigger({ diff --git a/source/renderer/app/stores/WalletsStore.ts b/source/renderer/app/stores/WalletsStore.ts index f7f4799e0a..2c29904715 100644 --- a/source/renderer/app/stores/WalletsStore.ts +++ b/source/renderer/app/stores/WalletsStore.ts @@ -57,6 +57,8 @@ import type { HardwareWalletExtendedPublicKeyResponse, } from '../../../common/types/hardware-wallets.types'; import { NetworkMagics } from '../../../common/types/cardano-node.types'; +import { EventCategories } from '../analytics'; +import { getEventNameFromWallet } from '../analytics/utils/getEventNameFromWallet'; /* eslint-disable consistent-return */ /** @@ -365,6 +367,10 @@ export default class WalletsStore extends Store { runInAction('update account public key', () => { this.activePublicKey = accountPublicKey; }); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Revealed wallet public key' + ); } catch (error) { throw error; } @@ -397,6 +403,10 @@ export default class WalletsStore extends Store { runInAction('update ICO public key', () => { this.icoPublicKey = icoPublicKey; }); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Revealed wallet multi-signature public key' + ); } catch (error) { throw error; } @@ -464,8 +474,13 @@ export default class WalletsStore extends Store { this.goToWalletRoute(restoredWallet.id); this.refreshWalletsData(); - this._restoreWalletResetRequests(); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Restored a software wallet', + this.walletKind + ); + this._restoreWalletResetRequests(); this._restoreWalletResetData(); } }; @@ -619,6 +634,10 @@ export default class WalletsStore extends Store { this.goToWalletRoute(wallet.id); this.refreshWalletsData(); this.actions.dialogs.closeActiveDialog.trigger(); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Created a new hardware wallet' + ); } } catch (error) { throw error; @@ -639,6 +658,10 @@ export default class WalletsStore extends Store { this.actions.dialogs.closeActiveDialog.trigger(); this.goToWalletRoute(wallet.id); this.refreshWalletsData(); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Created a new software wallet' + ); } }; _deleteWallet = async (params: { walletId: string; isLegacy: boolean }) => { @@ -681,6 +704,13 @@ export default class WalletsStore extends Store { }); } }); + + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Wallet deleted', + getEventNameFromWallet(walletToDelete) + ); + this.actions.dialogs.closeActiveDialog.trigger(); this.actions.walletsLocal.unsetWalletLocalData.trigger({ walletId: params.walletId, @@ -817,6 +847,11 @@ export default class WalletsStore extends Store { assets: formattedAssets, hasAssetsRemainingAfterTransaction, }); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Transaction made', + 'Software wallet' + ); this.refreshWalletsData(); this.actions.dialogs.closeActiveDialog.trigger(); this.sendMoneyRequest.reset(); @@ -1479,10 +1514,12 @@ export default class WalletsStore extends Store { note, address, filePath, + wallet, }: { note: string; address: string; filePath: string; + wallet: Wallet; }) => { const { currentLocale, @@ -1508,6 +1545,11 @@ export default class WalletsStore extends Store { this.actions.wallets.generateAddressPDFSuccess.trigger({ walletAddress, }); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Saved wallet address as PDF', + getEventNameFromWallet(wallet) + ); } catch (error) { throw new Error(error); } @@ -1515,9 +1557,11 @@ export default class WalletsStore extends Store { _saveQRCodeImage = async ({ address, filePath, + wallet, }: { address: string; filePath: string; + wallet: Wallet; }) => { try { await saveQRCodeImageChannel.send({ @@ -1528,6 +1572,11 @@ export default class WalletsStore extends Store { this.actions.wallets.saveQRCodeImageSuccess.trigger({ walletAddress, }); + this.analytics.sendEvent( + EventCategories.WALLETS, + 'Saved wallet address as QR code', + getEventNameFromWallet(wallet) + ); } catch (error) { throw new Error(error); } diff --git a/source/renderer/app/stores/index.ts b/source/renderer/app/stores/index.ts index f19c566371..56a5e6e958 100644 --- a/source/renderer/app/stores/index.ts +++ b/source/renderer/app/stores/index.ts @@ -24,6 +24,9 @@ import WalletSettingsStore from './WalletSettingsStore'; import WalletsLocalStore from './WalletsLocalStore'; import WalletsStore from './WalletsStore'; import WindowStore from './WindowStore'; +import { AnalyticsTracker } from '../analytics'; +import { Api } from '../api'; +import { ActionsMap } from '../actions'; export const storeClasses = { addresses: AddressesStore, @@ -82,12 +85,17 @@ function executeOnEveryStore(fn: (store: Store) => void) { }); } // Set up and return the stores for this app -> also used to reset all stores to defaults -export default action( - (api, actions, router): StoresMap => { +export const setUpStores = action( + ( + api: Api, + actions: ActionsMap, + router: RouterStore, + analyticsTracker: AnalyticsTracker + ): StoresMap => { function createStoreInstanceOf( StoreSubClass: Class ): T { - return new StoreSubClass(api, actions); + return new StoreSubClass(api, actions, analyticsTracker); } // Teardown existing stores diff --git a/source/renderer/app/stores/lib/Store.ts b/source/renderer/app/stores/lib/Store.ts index 6dee796702..d53f15e9bc 100644 --- a/source/renderer/app/stores/lib/Store.ts +++ b/source/renderer/app/stores/lib/Store.ts @@ -3,18 +3,18 @@ import type { ActionsMap } from '../../actions/index'; import type { StoresMap } from '../index'; import type { Api } from '../../api/index'; import type { Environment } from '../../../../common/types/environment.types'; +import { AnalyticsTracker } from '../../analytics'; export default class Store { stores: StoresMap; - api: Api; - actions: ActionsMap; environment: Environment = global.environment; _reactions: Array = []; - constructor(api: Api, actions: ActionsMap) { - this.api = api; - this.actions = actions; - } + constructor( + protected api: Api, + protected actions: ActionsMap, + protected analytics: AnalyticsTracker + ) {} registerReactions(reactions: Array<(...args: Array) => any>) { reactions.forEach((reaction) => diff --git a/source/renderer/index.ejs b/source/renderer/index.ejs index 7ee5a3f289..e627e30fa1 100644 --- a/source/renderer/index.ejs +++ b/source/renderer/index.ejs @@ -13,7 +13,7 @@ script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; - connect-src 'self' https://*.matomo.cloud/; + connect-src 'self' https://matomo.cw.iog.io/ http://localhost:8080; " > Daedalus diff --git a/storybook/stories/common/Widgets.stories.tsx b/storybook/stories/common/Widgets.stories.tsx index a8ac8c0543..f5eb7c7d76 100644 --- a/storybook/stories/common/Widgets.stories.tsx +++ b/storybook/stories/common/Widgets.stories.tsx @@ -23,6 +23,7 @@ import ButtonLink from '../../../source/renderer/app/components/widgets/ButtonLi import NormalSwitch from '../../../source/renderer/app/components/widgets/forms/NormalSwitch'; import { Separator } from '../../../source/renderer/app/components/widgets/separator/Separator'; import { CollapsibleSection } from '../../../source/renderer/app/components/widgets/collapsible-section/CollapsibleSection'; +import { VerticalSeparator } from '../../../source/renderer/app/components/wallet/widgets/VerticalSeparator'; const { intl: enIntl } = new IntlProvider({ locale: 'en-US', @@ -250,4 +251,9 @@ storiesOf('Common / Widgets', module) + )) + .add('VerticalSeparator', () => ( + + + )); diff --git a/storybook/stories/wallets/send/WalletSend.stories.tsx b/storybook/stories/wallets/send/WalletSend.stories.tsx index 5f43d24b25..27b0d9e727 100644 --- a/storybook/stories/wallets/send/WalletSend.stories.tsx +++ b/storybook/stories/wallets/send/WalletSend.stories.tsx @@ -19,6 +19,7 @@ import Wallet, { import WalletSendForm from '../../../../source/renderer/app/components/wallet/WalletSendForm'; import type { WalletTokens } from '../../../../source/renderer/app/api/assets/types'; import { WalletSendConfirmationDialogView } from '../../../../source/renderer/app/containers/wallet/dialogs/send-confirmation/SendConfirmation.view'; +import { noopAnalyticsTracker as analyticsTracker } from '../../../../source/renderer/app/analytics'; const allAssets = [ generateAssetToken( @@ -232,6 +233,7 @@ storiesOf('Wallets / Send', module) walletName="My wallet" onTokenPickerDialogClose={action('onTokenPickerDialogClose')} onTokenPickerDialogOpen={action('onTokenPickerDialogOpen')} + analyticsTracker={analyticsTracker} /> )) .add('Send - Hardware wallet verifying transaction', () => ( @@ -260,6 +262,7 @@ storiesOf('Wallets / Send', module) walletName="My wallet" onTokenPickerDialogClose={action('onTokenPickerDialogClose')} onTokenPickerDialogOpen={action('onTokenPickerDialogOpen')} + analyticsTracker={analyticsTracker} /> )) .add('Send - Hardware wallet verifying transaction succeeded', () => ( @@ -288,6 +291,7 @@ storiesOf('Wallets / Send', module) walletName="My wallet" onTokenPickerDialogClose={action('onTokenPickerDialogClose')} onTokenPickerDialogOpen={action('onTokenPickerDialogOpen')} + analyticsTracker={analyticsTracker} /> )) .add('Send - Hardware wallet verifying transaction failed', () => ( @@ -316,6 +320,7 @@ storiesOf('Wallets / Send', module) walletName="My wallet" onTokenPickerDialogClose={action('onTokenPickerDialogClose')} onTokenPickerDialogOpen={action('onTokenPickerDialogOpen')} + analyticsTracker={analyticsTracker} /> )) .add('Send - With Assets', () => ( @@ -347,6 +352,7 @@ storiesOf('Wallets / Send', module) walletName="My wallet" onTokenPickerDialogClose={action('onTokenPickerDialogClose')} onTokenPickerDialogOpen={action('onTokenPickerDialogOpen')} + analyticsTracker={analyticsTracker} /> )) .add('Wallet Send Confirmation Dialog With Assets', () => { diff --git a/translations/messages.json b/translations/messages.json index db054205c9..18a4cebc9c 100644 --- a/translations/messages.json +++ b/translations/messages.json @@ -2677,15 +2677,20 @@ "id": "settings.support.steps.reportProblem.link" }, { - "defaultMessage": "!!!You have opted in to analytics data collection. You can {changeAnalyticsSettingsLink}.", + "defaultMessage": "!!!You have opted in to analytics data collection. You can {changeAnalyticsSettingsLink}.", "description": "Analytics data collection description when user opted in", "id": "analytics.form.analyticsAcceptedDescription" }, { - "defaultMessage": "!!!You have opted out of analytics data collection. You can {changeAnalyticsSettingsLink}.", + "defaultMessage": "!!!You have opted out of analytics data collection. You can {changeAnalyticsSettingsLink}.", "description": "Analytics data collection description when user opted out ", "id": "analytics.form.analyticsDeclinedDescription" }, + { + "defaultMessage": "!!!You can {changeAnalyticsSettingsLink}.", + "description": "Change analytics settings link", + "id": "analytics.form.changeAnalyticsSettings" + }, { "defaultMessage": "!!!change this setting here", "description": "Change analytics settings link text", @@ -2714,7 +2719,7 @@ { "defaultMessage": "!!!Skip", "description": "Analytics data collection skip button text", - "id": "analytics.dialog.skipButton" + "id": "analytics.form.disallowButton" }, { "defaultMessage": "!!!Daedalus Privacy Policy", diff --git a/yarn.lock b/yarn.lock index 5421857215..6263079d33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3093,14 +3093,7 @@ "@types/react" "*" "@types/react-router" "*" -"@types/react-router@*": - version "5.1.18" - resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.18.tgz#c8851884b60bc23733500d86c1266e1cfbbd9ef3" - dependencies: - "@types/history" "^4.7.11" - "@types/react" "*" - -"@types/react-router@5.1.18": +"@types/react-router@*", "@types/react-router@5.1.18": version "5.1.18" resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.18.tgz#c8851884b60bc23733500d86c1266e1cfbbd9ef3" dependencies: @@ -11218,9 +11211,9 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" +lodash-es@4.17.15: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" lodash._reinterpolate@^3.0.0: version "3.0.0" @@ -11563,6 +11556,10 @@ mathml-tag-names@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" +matomo-tracker@2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/matomo-tracker/-/matomo-tracker-2.2.4.tgz#ee397d915d7b2e7964996ca28a0a03f4f0692453" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"