-
Notifications
You must be signed in to change notification settings - Fork 12
feat: add user_interaction trigger #243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: web/page-triggers-1
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, boolean> = new Map(); | ||
| private elementAppearedState: Map<string, boolean> = new Map(); | ||
| private activeElementSelectors: Set<string> = new Set(); | ||
| private userInteractionListeners: Map< | ||
| string, | ||
| { element: Element; handler: EventListener; interactionType: string }[] | ||
| > = new Map(); | ||
| private firedUserInteractions: Set<string> = 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would fail if an element matching the selector is added to the DOM later, it won't have listeners attached. I'd suggest istead of attaching listeners to each element, attach one listener to document and check if the event target matches the 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; | ||
| } | ||
| } | ||
|
Comment on lines
+441
to
+459
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would mean |
||
| } 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This always returns true even if |
||
| } 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', () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate subscribers and DOM listeners are being added. Consider making registration idempotent: keep a registry keyed by
|
||
| this.setupUserInteractionListenersForSelector( | ||
| selector, | ||
| interactionType, | ||
| minThresholdMs, | ||
| ); | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| private isPageObjectActive = <T extends MessageType>( | ||
| 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; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minThresholdMsis meant to be used for allinteractionTypes and not just forhover.minThresholdMsforclickevent can be interpreted in multiple ways (delayed trigger after click, click + linger, long click), so let's put a hold on to it for now .For
focus, it should be handled similarly to how hover is determined.