diff --git a/packages/sdk-install-modal-web/src/components.d.ts b/packages/sdk-install-modal-web/src/components.d.ts index 07a464fe6..fa7cade6e 100644 --- a/packages/sdk-install-modal-web/src/components.d.ts +++ b/packages/sdk-install-modal-web/src/components.d.ts @@ -5,6 +5,8 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; +import { TrackingEvents } from "./components/misc/tracking-events"; +export { TrackingEvents } from "./components/misc/tracking-events"; export namespace Components { interface MmInstallModal { /** @@ -47,6 +49,7 @@ declare global { interface HTMLMmInstallModalElementEventMap { "close": any; "startDesktopOnboarding": any; + "trackAnalytics": { event: TrackingEvents, params?: Record }; } interface HTMLMmInstallModalElement extends Components.MmInstallModal, HTMLStencilElement { addEventListener(type: K, listener: (this: HTMLMmInstallModalElement, ev: MmInstallModalCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; @@ -113,6 +116,7 @@ declare namespace LocalJSX { "link"?: string; "onClose"?: (event: MmInstallModalCustomEvent) => void; "onStartDesktopOnboarding"?: (event: MmInstallModalCustomEvent) => void; + "onTrackAnalytics"?: (event: MmInstallModalCustomEvent<{ event: TrackingEvents, params?: Record }>) => void; "preferDesktop"?: boolean; "sdkVersion"?: string; } diff --git a/packages/sdk-install-modal-web/src/components/misc/tracking-events.ts b/packages/sdk-install-modal-web/src/components/misc/tracking-events.ts new file mode 100644 index 000000000..6bfd70889 --- /dev/null +++ b/packages/sdk-install-modal-web/src/components/misc/tracking-events.ts @@ -0,0 +1,5 @@ +export enum TrackingEvents { + SDK_MODAL_VIEWED = 'sdk_modal_viewed', + SDK_MODAL_BUTTON_CLICKED = 'sdk_modal_button_clicked', + SDK_MODAL_TOGGLE_CHANGED = 'sdk_modal_toggle_changed', +} diff --git a/packages/sdk-install-modal-web/src/components/mm-install-modal/mm-install-modal.tsx b/packages/sdk-install-modal-web/src/components/mm-install-modal/mm-install-modal.tsx index 178b698e1..5e0b3c88c 100644 --- a/packages/sdk-install-modal-web/src/components/mm-install-modal/mm-install-modal.tsx +++ b/packages/sdk-install-modal-web/src/components/mm-install-modal/mm-install-modal.tsx @@ -10,6 +10,8 @@ import CloseButton from '../misc/CloseButton'; import Logo from '../misc/Logo'; import encodeQR from '@paulmillr/qr'; import { SimpleI18n } from '../misc/simple-i18n'; +import { TrackingEvents } from '../misc/tracking-events'; + @Component({ tag: 'mm-install-modal', styleUrl: '../style.css', @@ -31,6 +33,8 @@ export class InstallModal { @Event() startDesktopOnboarding: EventEmitter; + @Event() trackAnalytics: EventEmitter<{ event: TrackingEvents, params?: Record }>; + @State() tab: number = 1; @State() isDefaultTab: boolean = true; @@ -49,6 +53,16 @@ export class InstallModal { this.i18nInstance = new SimpleI18n(); } + componentDidLoad() { + this.trackAnalytics.emit({ + event: TrackingEvents.SDK_MODAL_VIEWED, + params: { + extensionInstalled: false, + tab: this.tab === 1 ? 'desktop' : 'mobile', + }, + }); + } + async connectedCallback() { await this.i18nInstance.init({ fallbackLng: 'en' @@ -70,11 +84,27 @@ export class InstallModal { } onStartDesktopOnboardingHandler() { + this.trackAnalytics.emit({ + event: TrackingEvents.SDK_MODAL_BUTTON_CLICKED, + params: { + button_type: 'install_extension', + tab: 'desktop', + }, + }); this.startDesktopOnboarding.emit(); } - setTab(newTab: number) { - this.tab = newTab + setTab(newTab: number, isUserAction: boolean = false) { + if (isUserAction) { + this.trackAnalytics.emit({ + event: TrackingEvents.SDK_MODAL_TOGGLE_CHANGED, + params: { + toggle: this.tab === 1 ? 'desktop_to_mobile' : 'mobile_to_desktop', + }, + }); + } + + this.tab = newTab; this.isDefaultTab = false; } @@ -84,8 +114,7 @@ export class InstallModal { } const t = (key: string) => this.i18nInstance.t(key); - - const currentTab = this.isDefaultTab ? this.preferDesktop ? 1 : 2 : this.tab + const currentTab = this.isDefaultTab ? this.preferDesktop ? 1 : 2 : this.tab; const svgElement = encodeQR(this.link, "svg", { ecc: "medium", @@ -110,13 +139,13 @@ export class InstallModal {
this.setTab(1)} + onClick={() => this.setTab(1, true)} class={`tab flexItem ${currentTab === 1 ? 'tabactive': ''}`} > {t('DESKTOP')}
this.setTab(2)} + onClick={() => this.setTab(2, true)} class={`tab flexItem ${currentTab === 2 ? 'tabactive': ''}`} > {t('MOBILE')} diff --git a/packages/sdk-react/src/EventsHandlers/useHandleTerminateEvent.test.tsx b/packages/sdk-react/src/EventsHandlers/useHandleTerminateEvent.test.tsx new file mode 100644 index 000000000..b5a84dfb5 --- /dev/null +++ b/packages/sdk-react/src/EventsHandlers/useHandleTerminateEvent.test.tsx @@ -0,0 +1,41 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useHandleTerminateEvent } from './useHandleTerminateEvent'; +import { EventHandlerProps } from '../MetaMaskProvider'; +import * as loggerModule from '../utils/logger'; + +describe('useHandleTerminateEvent', () => { + const spyLogger = jest.spyOn(loggerModule, 'logger'); + + const eventHandlerProps = { + setConnecting: jest.fn(), + setConnected: jest.fn(), + setError: jest.fn(), + debug: true, + } as unknown as EventHandlerProps; + + beforeEach(() => { + jest.clearAllMocks(); + + eventHandlerProps.setConnecting = jest.fn(); + eventHandlerProps.setConnected = jest.fn(); + eventHandlerProps.setError = jest.fn(); + }); + + it('should handle the terminate event correctly', () => { + const mockReason = { message: 'Terminated due to xyz', code: -32000 }; + + const { result } = renderHook(() => + useHandleTerminateEvent(eventHandlerProps), + ); + result.current(mockReason); + + expect(spyLogger).toHaveBeenCalledWith( + "[MetaMaskProvider: useHandleTerminateEvent()] on 'terminate' event.", + mockReason, + ); + + expect(eventHandlerProps.setConnecting).toHaveBeenCalledWith(false); + expect(eventHandlerProps.setConnected).toHaveBeenCalledWith(false); + expect(eventHandlerProps.setError).toHaveBeenCalledWith(mockReason); + }); +}); diff --git a/packages/sdk-react/src/EventsHandlers/useHandleTerminateEvent.ts b/packages/sdk-react/src/EventsHandlers/useHandleTerminateEvent.ts new file mode 100644 index 000000000..cd308f54b --- /dev/null +++ b/packages/sdk-react/src/EventsHandlers/useHandleTerminateEvent.ts @@ -0,0 +1,25 @@ +import { EthereumRpcError } from 'eth-rpc-errors'; +import { useCallback } from 'react'; +import { EventHandlerProps } from '../MetaMaskProvider'; +import { logger } from '../utils/logger'; + +export const useHandleTerminateEvent = ({ + debug, + setConnecting, + setConnected, + setError, +}: EventHandlerProps) => { + return useCallback( + (reason: unknown) => { + logger( + `[MetaMaskProvider: useHandleTerminateEvent()] on 'terminate' event.`, + reason, + ); + + setConnecting(false); + setConnected(false); + setError(reason as EthereumRpcError); + }, + [debug, setConnecting, setConnected, setError], + ); +}; diff --git a/packages/sdk-react/src/MetaMaskProvider.spec.tsx b/packages/sdk-react/src/MetaMaskProvider.spec.tsx index 541317577..b142a7b1f 100644 --- a/packages/sdk-react/src/MetaMaskProvider.spec.tsx +++ b/packages/sdk-react/src/MetaMaskProvider.spec.tsx @@ -117,7 +117,7 @@ describe('MetaMaskProvider Component', () => { }); expect(mockSdkOn).toHaveBeenCalledTimes(2); - expect(mockProviderOn).toHaveBeenCalledTimes(6); + expect(mockProviderOn).toHaveBeenCalledTimes(7); expect(mockSdkOn.mock.calls).toEqual([ ['service_status', expect.any(Function)], @@ -126,6 +126,7 @@ describe('MetaMaskProvider Component', () => { expect(mockProviderOn.mock.calls).toEqual([ ['_initialized', expect.any(Function)], + ['terminate', expect.any(Function)], ['connecting', expect.any(Function)], ['connect', expect.any(Function)], ['disconnect', expect.any(Function)], @@ -144,7 +145,7 @@ describe('MetaMaskProvider Component', () => { cleanup(); expect(mockSdkRemoveListener).toHaveBeenCalledTimes(2); - expect(mockProviderRemoveListener).toHaveBeenCalledTimes(6); + expect(mockProviderRemoveListener).toHaveBeenCalledTimes(7); expect(mockSdkRemoveListener.mock.calls).toEqual([ ['service_status', expect.any(Function)], @@ -156,6 +157,7 @@ describe('MetaMaskProvider Component', () => { ['connecting', expect.any(Function)], ['connect', expect.any(Function)], ['disconnect', expect.any(Function)], + ['terminate', expect.any(Function)], ['accountsChanged', expect.any(Function)], ['chainChanged', expect.any(Function)], ]); diff --git a/packages/sdk-react/src/MetaMaskProvider.tsx b/packages/sdk-react/src/MetaMaskProvider.tsx index 1f28a9373..045734583 100644 --- a/packages/sdk-react/src/MetaMaskProvider.tsx +++ b/packages/sdk-react/src/MetaMaskProvider.tsx @@ -23,6 +23,7 @@ import { useHandleInitializedEvent } from './EventsHandlers/useHandleInitialized import { useHandleOnConnectingEvent } from './EventsHandlers/useHandleOnConnectingEvent'; import { useHandleProviderEvent } from './EventsHandlers/useHandleProviderEvent'; import { useHandleSDKStatusEvent } from './EventsHandlers/useHandleSDKStatusEvent'; +import { useHandleTerminateEvent } from './EventsHandlers/useHandleTerminateEvent'; import { logger } from './utils/logger'; export interface EventHandlerProps { @@ -130,6 +131,8 @@ const MetaMaskProviderClient = ({ const onConnect = useHandleConnectEvent(eventHandlerProps); const onDisconnect = useHandleDisconnectEvent(eventHandlerProps); + + const onTerminate = useHandleTerminateEvent(eventHandlerProps); const onAccountsChanged = useHandleAccountsChangedEvent(eventHandlerProps); @@ -262,12 +265,18 @@ const MetaMaskProviderClient = ({ console.warn(`[MetaMaskProviderClient] activeProvider is undefined.`); return; } - setConnected(activeProvider.isConnected()); + + const isConnected = sdk.isExtensionActive() + ? !!activeProvider.getSelectedAddress() + : activeProvider.isConnected(); + + setConnected(isConnected); setAccount(activeProvider.getSelectedAddress() || undefined); setProvider(activeProvider); setChainId(activeProvider.getChainId() || undefined); activeProvider.on('_initialized', onInitialized); + activeProvider.on('terminate', onTerminate); activeProvider.on('connecting', onConnecting); activeProvider.on('connect', onConnect); activeProvider.on('disconnect', onDisconnect); @@ -296,6 +305,7 @@ const MetaMaskProviderClient = ({ activeProvider.removeListener('connecting', onConnecting); activeProvider.removeListener('connect', onConnect); activeProvider.removeListener('disconnect', onDisconnect); + activeProvider.removeListener('terminate', onTerminate); activeProvider.removeListener('accountsChanged', onAccountsChanged); activeProvider.removeListener('chainChanged', onChainChanged); sdk.removeListener(EventType.SERVICE_STATUS, onSDKStatusEvent); diff --git a/packages/sdk/src/services/MetaMaskSDK/ConnectionManager/terminate.test.ts b/packages/sdk/src/services/MetaMaskSDK/ConnectionManager/terminate.test.ts index 8e16b7c07..a680b4eca 100644 --- a/packages/sdk/src/services/MetaMaskSDK/ConnectionManager/terminate.test.ts +++ b/packages/sdk/src/services/MetaMaskSDK/ConnectionManager/terminate.test.ts @@ -90,7 +90,7 @@ describe('terminate', () => { it('should not switch providers if extensionOnly option is true', async () => { instance.options.extensionOnly = true; await terminate(instance); - expect(mockEmit).not.toHaveBeenCalled(); + expect(mockEmit).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/sdk/src/services/MetaMaskSDK/ConnectionManager/terminate.ts b/packages/sdk/src/services/MetaMaskSDK/ConnectionManager/terminate.ts index 2f6d44cf0..256984ed0 100644 --- a/packages/sdk/src/services/MetaMaskSDK/ConnectionManager/terminate.ts +++ b/packages/sdk/src/services/MetaMaskSDK/ConnectionManager/terminate.ts @@ -48,6 +48,11 @@ export async function terminate(instance: MetaMaskSDK) { } if (instance.options.extensionOnly) { + instance.emit( + MetaMaskSDKEvent.ProviderUpdate, + PROVIDER_UPDATE_TYPE.TERMINATE, + ); + logger( `[MetaMaskSDK: terminate()] extensionOnly --- prevent switching providers`, ); diff --git a/packages/sdk/src/services/RemoteConnection/ModalManager/showInstallModal.test.ts b/packages/sdk/src/services/RemoteConnection/ModalManager/showInstallModal.test.ts index 6dc88bdf7..22967ca2f 100644 --- a/packages/sdk/src/services/RemoteConnection/ModalManager/showInstallModal.test.ts +++ b/packages/sdk/src/services/RemoteConnection/ModalManager/showInstallModal.test.ts @@ -52,6 +52,7 @@ describe('showInstallModal', () => { terminate: expect.any(Function), debug: state.developerMode, connectWithExtension: expect.any(Function), + onAnalyticsEvent: expect.any(Function), }); expect(mockModalsInstall).toHaveBeenCalledTimes(1); expect(mockInstallModalMount).toHaveBeenCalledWith(link); diff --git a/packages/sdk/src/services/RemoteConnection/ModalManager/showInstallModal.ts b/packages/sdk/src/services/RemoteConnection/ModalManager/showInstallModal.ts index 7f181419e..97001f366 100644 --- a/packages/sdk/src/services/RemoteConnection/ModalManager/showInstallModal.ts +++ b/packages/sdk/src/services/RemoteConnection/ModalManager/showInstallModal.ts @@ -1,3 +1,4 @@ +import { TrackingEvents } from '@metamask/sdk-communication-layer'; import { logger } from '../../../utils/logger'; import { RemoteConnectionProps, @@ -34,6 +35,22 @@ export function showInstallModal( options.connectWithExtensionProvider?.(); return false; }, + onAnalyticsEvent: ({ + event, + params, + }: { + event: TrackingEvents; + params?: Record; + }) => { + const extended = { + ...params, + sdkVersion: options.sdk.getVersion(), + dappId: options.dappMetadata?.name, + source: options._source, + url: options.dappMetadata?.url, + }; + state.analytics?.send({ event, params: extended }); + }, }); state.installModal?.mount?.(link); } diff --git a/packages/sdk/src/services/RemoteConnection/RemoteConnection.ts b/packages/sdk/src/services/RemoteConnection/RemoteConnection.ts index db873a882..7fea22475 100644 --- a/packages/sdk/src/services/RemoteConnection/RemoteConnection.ts +++ b/packages/sdk/src/services/RemoteConnection/RemoteConnection.ts @@ -8,6 +8,7 @@ import { KeyInfo, RemoteCommunication, StorageManagerProps, + TrackingEvents, } from '@metamask/sdk-communication-layer'; import { MetaMaskInstaller } from '../../Platform/MetaMaskInstaller'; import { PlatformManager } from '../../Platform/PlatfformManager'; @@ -55,13 +56,20 @@ export interface RemoteConnectionProps { */ modals: { onPendingModalDisconnect?: () => void; - install?: (params: { + install?: (args: { link: string; debug?: boolean; preferDesktop?: boolean; installer: MetaMaskInstaller; terminate?: () => void; connectWithExtension?: () => void; + onAnalyticsEvent: ({ + event, + params, + }: { + event: TrackingEvents; + params?: Record; + }) => void; }) => { unmount?: (shouldTerminate?: boolean) => void; mount?: (link: string) => void; diff --git a/packages/sdk/src/ui/InstallModal/InstallModal-web.ts b/packages/sdk/src/ui/InstallModal/InstallModal-web.ts index d4c253453..fcf99a626 100644 --- a/packages/sdk/src/ui/InstallModal/InstallModal-web.ts +++ b/packages/sdk/src/ui/InstallModal/InstallModal-web.ts @@ -1,3 +1,4 @@ +import { TrackingEvents } from '@metamask/sdk-communication-layer'; import packageJson from '../../../package.json'; import { MetaMaskInstaller } from '../../Platform/MetaMaskInstaller'; import { logger } from '../../utils/logger'; @@ -10,6 +11,7 @@ const sdkWebInstallModal = ({ terminate, connectWithExtension, preferDesktop, + onAnalyticsEvent, }: { link: string; debug?: boolean; @@ -17,6 +19,13 @@ const sdkWebInstallModal = ({ installer: MetaMaskInstaller; terminate?: () => void; connectWithExtension?: () => void; + onAnalyticsEvent: ({ + event, + params, + }: { + event: TrackingEvents; + params?: Record; + }) => void; }) => { let modalLoader: ModalLoader | null = null; let div: HTMLDivElement | null = null; @@ -94,6 +103,7 @@ const sdkWebInstallModal = ({ link, metaMaskInstaller: installer, onClose: unmount, + onAnalyticsEvent, }) .catch((err) => { console.error(`[UI: InstallModal-web: sdkWebInstallModal()]`, err); diff --git a/packages/sdk/src/ui/InstallModal/Modal-web.ts b/packages/sdk/src/ui/InstallModal/Modal-web.ts index 3e58207f4..7eae806bd 100644 --- a/packages/sdk/src/ui/InstallModal/Modal-web.ts +++ b/packages/sdk/src/ui/InstallModal/Modal-web.ts @@ -1,3 +1,4 @@ +import { TrackingEvents } from '@metamask/sdk-communication-layer'; import type { Components } from '@metamask/sdk-install-modal-web'; export interface InstallWidgetProps extends Components.MmInstallModal { @@ -6,6 +7,10 @@ export interface InstallWidgetProps extends Components.MmInstallModal { metaMaskInstaller: { startDesktopOnboarding: () => void; }; + onAnalyticsEvent: (event: { + event: TrackingEvents; + params?: Record; + }) => void; } export interface PendingWidgetProps extends Components.MmPendingModal { @@ -80,6 +85,9 @@ export default class ModalLoader { 'startDesktopOnboarding', props.metaMaskInstaller.startDesktopOnboarding, ); + + modal.addEventListener('trackAnalytics', ((e: CustomEvent) => + props.onAnalyticsEvent?.(e.detail)) as EventListener); props.parentElement.appendChild(modal); }