diff --git a/packages/experiment-tag/src/message-bus.ts b/packages/experiment-tag/src/message-bus.ts index 14d27fd7..c9c073e4 100644 --- a/packages/experiment-tag/src/message-bus.ts +++ b/packages/experiment-tag/src/message-bus.ts @@ -17,6 +17,10 @@ export type ElementVisiblePayload = { mutationList: MutationRecord[] }; export type AnalyticsEventPayload = AnalyticsEvent; export type ManualTriggerPayload = { name: string }; export type UrlChangePayload = { updateActivePages?: boolean }; +export type UserInteractionPayload = { + selector: string; + interactionType: 'click' | 'hover' | 'focus'; +}; export type MessagePayloads = { element_appeared: ElementAppearedPayload; @@ -24,6 +28,7 @@ export type MessagePayloads = { url_change: UrlChangePayload; analytics_event: AnalyticsEventPayload; manual: ManualTriggerPayload; + user_interaction: UserInteractionPayload; }; export type MessageType = keyof MessagePayloads; diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index c62c3b9b..6c3b3213 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -2,11 +2,12 @@ import { EvaluationEngine } from '@amplitude/experiment-core'; import { DefaultWebExperimentClient, INJECT_ACTION } from './experiment'; import { - MessageBus, - MessagePayloads, ElementAppearedPayload, ManualTriggerPayload, + MessageBus, + MessagePayloads, MessageType, + UserInteractionPayload, } from './message-bus'; import { DebouncedMutationManager } from './mutation-manager'; import { @@ -15,6 +16,7 @@ import { ManualTriggerValue, PageObject, PageObjects, + UserInteractionTriggerValue, } from './types'; const evaluationEngine = new EvaluationEngine(); @@ -41,6 +43,11 @@ export class SubscriptionManager { private elementVisibilityState: Map = new Map(); private elementAppearedState: Map = new Map(); private activeElementSelectors: Set = new Set(); + private userInteractionListeners: Map< + string, + { element: Element; handler: EventListener; interactionType: string }[] + > = new Map(); + private firedUserInteractions: Set = new Set(); constructor( webExperimentClient: DefaultWebExperimentClient, @@ -66,6 +73,7 @@ export class SubscriptionManager { } this.setupMutationObserverPublisher(); this.setupVisibilityPublisher(); + this.setupUserInteractionPublisher(); this.setupPageObjectSubscriptions(); this.setupUrlChangeReset(); // Initial check for elements that already exist @@ -181,12 +189,14 @@ export class SubscriptionManager { // Reset element state on URL navigation this.messageBus.subscribe('url_change', () => { this.elementAppearedState.clear(); + this.firedUserInteractions.clear(); this.activeElementSelectors.clear(); const elementSelectors = this.getElementSelectors(); elementSelectors.forEach((selector) => this.activeElementSelectors.add(selector), ); this.setupVisibilityPublisher(); + this.setupUserInteractionPublisher(); this.checkInitialElements(); }); }; @@ -413,6 +423,127 @@ export class SubscriptionManager { wrapHistoryMethods(); }; + private setupUserInteractionListenersForSelector = ( + selector: string, + interactionType: 'click' | 'hover' | 'focus', + minThresholdMs?: number, + ): boolean => { + try { + const elements = this.globalScope.document.querySelectorAll(selector); + + elements.forEach((element) => { + let interactionStartTime: number | null = null; + + const handler = (event: Event) => { + if (interactionType === 'hover') { + if (event.type === 'mouseenter') { + interactionStartTime = Date.now(); + } else if (event.type === 'mouseleave') { + if (interactionStartTime !== null) { + const interactionDuration = Date.now() - interactionStartTime; + if ( + !minThresholdMs || + interactionDuration >= minThresholdMs + ) { + const interactionKey = `${selector}:${interactionType}:${ + minThresholdMs || 0 + }`; + this.firedUserInteractions.add(interactionKey); + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); + } + interactionStartTime = null; + } + } + } else if (interactionType === 'focus') { + if (event.type === 'focus') { + const interactionKey = `${selector}:${interactionType}`; + this.firedUserInteractions.add(interactionKey); + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); + } + } else { + const interactionKey = `${selector}:${interactionType}`; + this.firedUserInteractions.add(interactionKey); + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); + } + }; + + if (interactionType === 'click') { + element.addEventListener('click', handler); + const key = `${selector}:${interactionType}`; + const listeners = this.userInteractionListeners.get(key) || []; + listeners.push({ element, handler, interactionType: 'click' }); + this.userInteractionListeners.set(key, listeners); + } else if (interactionType === 'hover') { + element.addEventListener('mouseenter', handler); + element.addEventListener('mouseleave', handler); + const key = `${selector}:${interactionType}`; + const listeners = this.userInteractionListeners.get(key) || []; + listeners.push( + { element, handler, interactionType: 'mouseenter' }, + { element, handler, interactionType: 'mouseleave' }, + ); + this.userInteractionListeners.set(key, listeners); + } else if (interactionType === 'focus') { + element.addEventListener('focus', handler); + const key = `${selector}:${interactionType}`; + const listeners = this.userInteractionListeners.get(key) || []; + listeners.push({ element, handler, interactionType: 'focus' }); + this.userInteractionListeners.set(key, listeners); + } + }); + + return true; + } catch { + return false; + } + }; + + private setupUserInteractionPublisher = () => { + // Clear any existing listeners first + this.userInteractionListeners.forEach((listeners) => { + listeners.forEach(({ element, handler, interactionType }) => { + element.removeEventListener(interactionType, handler); + }); + }); + this.userInteractionListeners.clear(); + + for (const pages of Object.values(this.pageObjects)) { + for (const page of Object.values(pages)) { + if (page.trigger_type === 'user_interaction') { + const triggerValue = + page.trigger_value as UserInteractionTriggerValue; + const { selector, interactionType, minThresholdMs } = triggerValue; + + const success = this.setupUserInteractionListenersForSelector( + selector, + interactionType, + minThresholdMs, + ); + + if (!success) { + // Invalid selector or elements don't exist yet - wait for element_appeared + this.messageBus.subscribe('element_appeared', () => { + this.setupUserInteractionListenersForSelector( + selector, + interactionType, + minThresholdMs, + ); + }); + } + } + } + } + }; + private isPageObjectActive = ( page: PageObject, message: MessagePayloads[T], @@ -501,6 +632,20 @@ export class SubscriptionManager { return this.elementVisibilityState.get(observerKey) ?? false; } + case 'user_interaction': { + const triggerValue = page.trigger_value as UserInteractionTriggerValue; + // Include minThresholdMs in key for hover to differentiate between different durations + const interactionKey = + triggerValue.interactionType === 'hover' + ? `${triggerValue.selector}:${triggerValue.interactionType}:${ + triggerValue.minThresholdMs || 0 + }` + : `${triggerValue.selector}:${triggerValue.interactionType}`; + + // Check if this interaction has already fired + return this.firedUserInteractions.has(interactionKey); + } + default: return false; } diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 42639506..8360ff66 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -45,6 +45,14 @@ export interface ManualTriggerValue { name: string; } +export type UserInteractionType = 'click' | 'hover' | 'focus'; + +export interface UserInteractionTriggerValue { + selector: string; + interactionType: UserInteractionType; + minThresholdMs?: number; +} + export type PageObject = { id: string; name: string; @@ -54,6 +62,7 @@ export type PageObject = { | ElementAppearedTriggerValue | ElementVisibleTriggerValue | ManualTriggerValue + | UserInteractionTriggerValue | Record; };