/** * User interaction class to track user interactions on the page * @author [Vivek Sudarsan] * @version 0.1.0 */ // Import constants import { glob } from '.'; import { allElementTypes, titleElementTypes, buttonElementTypes, imageElementTypes, documentElementTypes, IGNORED_CLASSES, IGNORED_PHRASES, INTERACTION_TYPES, TOUCH_TYPES, textElementTypes, containerElementTypes, } from './util/constants'; // Import helpers import apiRequest from './services/api/request'; import { Batcher } from './util/batch'; import { avinaLog } from './util/helpers'; export class UserInteraction { // Class variables private startTimestamp: any = null; private isScrolling: boolean = false; private batcher: any; /** * Create a new UserInteraction instance * with a null start timestamp and batcher */ constructor() { this.startTimestamp = null; this.batcher = null; this.isScrolling = false; // Log that interactions are enabled avinaLog('Interactions enabled...'); } /** * Initialize the UserInteraction class * and add event listeners for user interactions */ init = () => { // initialize the listeners after a delay // to ensure that the user is actually engaging // instead of bouncing immediately setTimeout(() => { // Add event listeners for user interactions this.visibilityChangeListener(); this.scrollListener(); this.copyListener(); this.selectListener(); this.hoverListener(); this.screenshotListener(); // Send an initial payload of visible elements const payload = this.getVisibleElementsText(document.body); avinaLog(`Initial Load: ${payload}`); this.sendInteractionData(payload); }, 5000); }; /** * Gets all visible elements that match our criteria * and returns the text content of those elements * @param element * @returns */ getVisibleElementsText = (element: any) => { const visibleTexts = new Set(); const elementTagName = element.tagName.toLowerCase(); if ( this.isElementVisible(element, elementTagName) && allElementTypes.concat(documentElementTypes).includes(elementTagName) ) { element.childNodes.forEach((node: any, idx: number) => { // this child node is just text, grab the text if it's not an empty string if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) { const textContent = element.textContent.trim(); const textLength = textContent.split(' ').length; // we have some rough heuristics to determine if this is a visible text // and filter out common phrases or classes that we don't want to track if ( (textLength >= 3 || titleElementTypes.includes(elementTagName) || element.className.toLowerCase().includes('title')) && !IGNORED_CLASSES.includes(element.className.toLowerCase()) && !IGNORED_PHRASES.includes(textContent.toLowerCase()) && !IGNORED_PHRASES.some(phrase => textContent.toLowerCase().includes(phrase) ) ) { const payload = this.constructPayload( INTERACTION_TYPES.VIEW, element ); visibleTexts.add(payload); if (window.debugInteractions) { element.style.transition = 'opacity 0.25s ease'; element.style.border = `${Math.max( (1 - this.calculateDistanceFromCenter(element)) * 4, 0.4 )}px solid green`; element.style.backgroundColor = `rgba(0, 255, 0, 0.1)`; element.style.opacity = `${Math.max( 1 - this.calculateDistanceFromCenter(element), 0.1 )}`; } } } else if (node.nodeType === Node.ELEMENT_NODE) { this.getVisibleElementsText(node).forEach(text => visibleTexts.add(text) ); } else { if (window.debugInteractions) { element.style.border = '1px solid red'; } } }); } else { if (window.debugInteractions) { avinaLog(`Element handled differently: ${elementTagName}`); if (elementTagName === 'a' || elementTagName === 'button') { element.style.transition = 'opacity 0.25s ease'; element.style.border = `2px solid orange`; element.style.backgroundColor = 'rgba(255, 165, 0, 0.1)'; } else if (elementTagName === 'img') { element.style.transition = 'opacity 0.25s ease'; element.style.border = `2px solid purple`; element.style.backgroundColor = 'rgba(128, 0, 128, 0.1)'; } } } return Array.from(visibleTexts); }; /** * Gets the text content of button and link elements * @param element * @returns */ getHoverElementsText = () => { const visibleTexts = new Set(); const elements = document.querySelectorAll(':hover'); elements.forEach((el: any) => { const elementTagName = el.tagName.toLowerCase(); // Hovering over a button or link if (buttonElementTypes.includes(elementTagName)) { // this child node is just text, grab the text if it's not an empty string const payload = this.constructPayload(INTERACTION_TYPES.HOVER, el); visibleTexts.add(payload); } // Hovering over an image if (imageElementTypes.includes(elementTagName)) console.log(el.src); // Hovering over a text element if (textElementTypes.includes(elementTagName)) { const payload = this.constructPayload(INTERACTION_TYPES.HOVER, el); visibleTexts.add(payload); } }); return Array.from(visibleTexts); }; /** * Checks if a given element is visible in the viewport * @param element * @param tagName * @returns */ isElementVisible = (element: any, tagName: any) => { if (documentElementTypes.includes(tagName)) return true; const rect = element.getBoundingClientRect(); const viewHeight = Math.max( document.documentElement.clientHeight, window.innerHeight ); const viewWidth = Math.max( document.documentElement.clientWidth, window.innerWidth ); const style = window.getComputedStyle(element); // Only apply position checks if element takes up less than 30% of the screen // This check is largely to prevent banners from appearing in every interaction // But shouldn't prevent modals from showing up const screenCoverage = this.calculateElementScreenCoverage( rect, viewHeight, viewWidth ); const allowedPosition = this.isPositionAllowed(style, screenCoverage); // Check if element bottom is visible (only for non-container elements) const isContainerElement = containerElementTypes.includes(tagName); const isBottomVisible = isContainerElement || rect.bottom > 0; return ( isBottomVisible && rect.top < viewHeight && rect.left < viewWidth && rect.right > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && style.fontFamily !== 'monospace' && allowedPosition ); }; /** * Construct full payload with text and interaction data * @param element * @param textContent * @param eventType * @returns */ constructPayload = ( eventType: string, element?: any, textContent?: string ) => { const textPayload = this.createTextPayload(element, textContent); const interactionPayload = this.createInteractionPayload( element, eventType ); return { interaction: interactionPayload, text: textPayload, }; }; /** * Create text payload for the given element * @param element */ createTextPayload = (element?: any, textContent?: string) => { if (!element && !textContent) return; const basePayload: any = { type: 'text', text: textContent || '', font_size: '', url: window.location.href, domain: window.location.hostname, tag: '', }; if (element) { basePayload.text = element.textContent.trim(); basePayload.font_size = getComputedStyle(element).fontSize; basePayload.tag = element.tagName.toLowerCase(); // Get all attributes of the element if (element.hasAttributes()) { basePayload['attr'] = Array.from(element.attributes).reduce( (acc: any, attr: any) => { acc[attr.name] = attr.value; return acc; }, {} ); } } return basePayload; }; /** * Create interaction payload for the given element * @param element */ createInteractionPayload = ( element: any, eventType: typeof INTERACTION_TYPES ) => { // Construct the base payload for the interaction const basePayload = { contact_id: glob.visitorId, type: 'interaction', event_type: eventType, dist_from_center_percent: this.calculateDistanceFromCenter(element), dist_from_top_abs: this.calculateDistanceFromTop(element), dist_from_left_abs: this.calculateDistanceFromLeft(element), touch_id: glob.touchpoints[TOUCH_TYPES.VIEW], url_params: window.location.search, start_timestamp: this.startTimestamp, end_timestamp: Date.now(), }; return basePayload; }; // Calculate the distance from an element to the center of the screen calculateDistanceFromCenter = (element: any) => { if (!element) return 0; const rect = element.getBoundingClientRect(); const centerY = window.innerHeight / 2; const distanceFromCenter = Math.abs(centerY - rect.top); return distanceFromCenter / centerY; }; calculateDistanceFromTop = (element: any) => { if (!element) return 0; const rect = element.getBoundingClientRect(); // Get the distance from the top of the page even if the page is scrolled const distanceFromTop = rect.top + window.scrollY; return distanceFromTop; }; calculateDistanceFromLeft = (element: any) => { if (!element) return 0; const rect = element.getBoundingClientRect(); // Get the distance from the left of the page even if the page is scrolled const distanceFromLeft = rect.left + window.scrollX; return distanceFromLeft; }; calculateElementScreenCoverage = ( rect: any, viewHeight: number, viewWidth: number ): number => { const elementArea = rect.width * rect.height; const screenArea = viewHeight * viewWidth; return (elementArea / screenArea) * 100; }; isPositionAllowed = ( style: any, screenCoveragePercent: number, maxCoverageThreshold: number = 30 ): boolean => { // Skip position checks for large elements like modals if (screenCoveragePercent >= maxCoverageThreshold) { return true; } const restrictedPositions = ['fixed', 'sticky']; return !restrictedPositions.includes(style.position); }; // Start the touchpoint timer startTimer = () => { this.startTimestamp = Date.now(); }; // Event listeners for user interactions // (scroll, copy, select, hover, screenshot, etc.) /** * Event listener for scrollend event * @param element */ scrollListener = () => { let lastProcessedTime = 0; let lastScrollPosition = window.scrollY; // Thresholds for scroll events const minTimeBetweenUpdates = 1500; // ms const significantChangeThreshold = 100; // px const processScroll = () => { const currentTime = Date.now(); const currentPosition = window.scrollY; const timeSinceLastProcess = currentTime - lastProcessedTime; const positionChange = Math.abs(currentPosition - lastScrollPosition); if ( timeSinceLastProcess >= minTimeBetweenUpdates && positionChange >= significantChangeThreshold ) { this.isScrolling = false; const payload = this.getVisibleElementsText(document.body); this.sendInteractionData(payload); lastProcessedTime = currentTime; lastScrollPosition = currentPosition; } }; // Scroll event handlers let scrollTimeout: any; const scrollEventHandler = () => { if (!this.isScrolling) { this.isScrolling = true; this.startTimer(); } // Clear the timeout and set a new one clearTimeout(scrollTimeout); // Debounce the scroll event scrollTimeout = setTimeout(() => { this.isScrolling = false; processScroll(); }, 250); }; const scrollEndEventHandler = () => { clearTimeout(scrollTimeout); this.isScrolling = false; processScroll(); }; // Add event listeners for scroll events on the document document.addEventListener('scroll', () => { scrollEventHandler(); }); document.addEventListener('scrollend', () => { scrollEndEventHandler(); }); // Also add listeners for avina-track-scroll classes const elements = document.querySelectorAll('.avina-track-scroll'); elements.forEach(element => { element.addEventListener('scroll', () => { scrollEventHandler(); }); element.addEventListener('scrollend', () => { scrollEndEventHandler(); }); }); }; /** * Event listener for copy event */ copyListener = () => { document.addEventListener('copy', (event: any) => { const selection = document.getSelection()?.toString(); const payload = this.constructPayload( INTERACTION_TYPES.COPY, null, selection ); this.sendInteractionData(payload); }); }; /** * Event listener for select event */ selectListener = () => { let debounceTimeout: any; document.addEventListener('selectionchange', () => { clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { const selectedText = document .getSelection() ?.toString() .trim(); if (selectedText && selectedText.split(' ').length > 1) { const payload = this.constructPayload( INTERACTION_TYPES.HIGHLIGHT, null, selectedText ); this.sendInteractionData(payload); } }, 500); }); }; /** * Event listener for hover event */ hoverListener = () => { let debounceTimeout: any; document.addEventListener('mouseover', event => { clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { const payload = this.getHoverElementsText(); this.sendInteractionData(payload); }, 2000); }); }; /** * Event listener for screenshot keystroke pattern * (Command + Shift) */ screenshotListener = () => { let keysPressed: any = {}; document.addEventListener('keydown', event => { keysPressed[event.key] = true; if (keysPressed['Meta'] && keysPressed['Shift']) { const payload: any = this.getVisibleElementsText(document.body); // update the event type to screenshot payload.forEach((element: any) => { element.interaction.event_type = INTERACTION_TYPES.SCREENSHOT; }); this.sendInteractionData(payload); keysPressed = {}; } }); document.addEventListener('keyup', event => { delete keysPressed[event.key]; }); }; visibilityChangeListener = () => { document.addEventListener('visibilitychange', event => { if (document.visibilityState === 'hidden') { const payload: any = this.getVisibleElementsText(document.body); avinaLog(`Visibility Change: ${payload}`); this.sendInteractionData(payload, true); } }); }; /** * Queues interaction data to be sent to server * @param data */ async sendInteractionData(data: any, keepAlive: boolean = false) { // Example payload: // { // 'customer': 'string', // 'payload': [ // { // 'interaction': { // 'contact_id': 'string', # planetscale contact id // 'type': 'string', // 'event_type': 'string', // 'start_timestamp': 'int', // 'end_timestamp': 'int', // 'dist_from_center_percent': 'float', // 'dist_from_top_abs': 'float', // 'touch_id': 'string', # planetscale touch id // 'url_params': 'string' // } // 'text': { // 'type': 'string', // 'text': 'string', // 'font_size': 'int', // 'web_url': 'string', // 'domain': 'string' // 'tag': 'string' // } // } // ] // } // if no data, return early if (!data || data.length === 0) return; // if data is not an array, make it an array if (!Array.isArray(data)) data = [data]; if (glob.workspaceName) { await apiRequest({ method: 'post', resource: 'avina-user-events/web-interaction/', data: { customer: glob.workspaceName, payload: data, }, }); } // Send interaction data to the server // if (!this.batcher) { // this.batcher = new Batcher(10, 5000, async (batch: any) => { // await apiRequest({ // method: 'post', // resource: 'user-events/web-interaction', // data: { // customer: glob.workspaceName, // payload: batch, // }, // }); // }); // } // Queue the data to be sent // if (data.length > 0) this.batcher.addToQueue(data); } }