{"version":3,"sources":["webpack:///./app/javascript/packs/feedEvents.js"],"names":["tracker","queue","processInterval","observer","IntersectionObserver","entries","forEach","entry","isIntersecting","intersectionRatio","queueMicrotask","post","target","dataset","impressionRecorded","queueEvent","categoryImpression","root","rootMargin","threshold","beaconEnabled","nextFeedPosition","observeFeedElements","feedContainer","document","getElementById","feedItemsRoot","_feedContainer$datase","feedCategoryClick","feedCategoryImpression","feedContextType","categoryClick","contextType","setInterval","submitEventsBatch","disconnect","findAndTrackFeedItems","addEventListener","visibilityState","window","Array","from","children","element","_element$dataset","classList","contains","feedContentId","feedPosition","trackFeedClickListener","observe","event","currentTarget","clickRecorded","category","_post$dataset","push","article_id","article_position","context_type","length","tokenMeta","querySelector","authenticity_token","getAttribute","data","Blob","JSON","stringify","feed_events","type","navigator","sendBeacon","fetch","method","headers","body","credentials","keepalive","error","console","fallbackRequest"],"mappings":"2FAAA,+DAIMA,EAAU,CACdC,MAAO,GACPC,gBAAiB,KACjBC,SAAU,IAAIC,sBAmFhB,SAA8BC,GAC5BA,EAAQC,SAAQ,SAACC,GAGXA,EAAMC,gBAAkBD,EAAME,mBA5FZ,KA6FpBC,gBAAe,WACb,IAAMC,EAAOJ,EAAMK,OACdD,EAAKE,QAAQC,qBAChBC,EAAWJ,EAAMX,EAAQgB,oBACzBL,EAAKE,QAAQC,oBAAqB,EAEtC,GAEJ,GACF,GAjG2D,CACvDG,KAAM,KACNC,WAAY,MACZC,UARsB,MAUxBC,eAAe,EACfC,iBAAkB,MAUb,SAASC,IACd,IAAMC,EAAgBC,SAASC,eAAe,mBAExCC,EAAgBF,SAASC,eAAe,yBAE9C,GAAMF,GAAiBG,EAAvB,CAEA,IAAAC,EACEJ,EAAcV,QADRe,EAAiBD,EAAjBC,kBAAmBC,EAAsBF,EAAtBE,uBAAwBC,EAAeH,EAAfG,gBAInD9B,EAAQ+B,cAAgBH,EACxB5B,EAAQgB,mBAAqBa,EAC7B7B,EAAQgC,YAAcF,EACtB9B,EAAQE,kBAARF,EAAQE,gBAAoB+B,YAAYC,EApClB,MAqCtBlC,EAAQG,SAASgC,aACjBnC,EAAQqB,iBAAmB,EAE3Be,EAAsBV,GAqCtBF,SAASa,iBAAiB,oBAAoB,WACZ,UAA5Bb,SAASc,iBAA6BJ,GAC5C,IACAK,OAAOF,iBAAiB,eAAgBH,EArDK,CAe/C,CAUA,SAASE,EAAsBnB,GAC7BuB,MAAMC,KAAKxB,EAAKyB,UAAUpC,SAAQ,SAA0BqC,GAAa,IAADC,EAClED,EAAQE,UAAUC,SAAS,iBAE7BV,EAAsBO,GACE,QAAnBC,EAAID,EAAQ9B,eAAO,IAAA+B,GAAfA,EAAiBG,gBAC1BJ,EAAQ9B,QAAQmC,aAAehD,EAAQqB,iBAEvCsB,EAAQN,iBAAiB,YAAaY,GAAwB,GAC9DjD,EAAQG,SAAS+C,QAAQP,GAEzB3C,EAAQqB,kBAAoB,EAEhC,GACF,CA+CA,SAAS4B,EAAuBE,GAC9B,IAAMxC,EAAOwC,EAAMC,cAEdzC,EAAKE,QAAQwC,gBAChBtC,EAAWJ,EAAMX,EAAQ+B,eACzBpB,EAAKE,QAAQwC,eAAgB,EAC7BnB,IAEJ,CAEA,SAASnB,EAAWJ,EAAM2C,GACxB,IAAAC,EAAwC5C,EAAKE,QAArCkC,EAAaQ,EAAbR,cAAeC,EAAYO,EAAZP,aAEvBhD,EAAQC,MAAMuD,KAAK,CACjBC,WAAYV,EACZW,iBAAkBV,EAClBM,WACAK,aAAc3D,EAAQgC,cAGpBhC,EAAQC,MAAM2D,QAtIG,IAuInB1B,GAEJ,CAUA,SAASA,IACP,GAA6B,IAAzBlC,EAAQC,MAAM2D,OAAlB,CAEA,IAAMC,EAAYrC,SAASsC,cAAc,2BACnCC,EAA8B,OAATF,QAAS,IAATA,OAAS,EAATA,EAAWG,aAAa,WAEnD,GAAIhE,EAAQoB,cAAe,CAIzB,IAAM6C,EAAO,IAAIC,KACf,CAACC,KAAKC,UAAU,CAAEL,qBAAoBM,YAAarE,EAAQC,SAC3D,CAAEqE,KAAM,qBAGVtE,EAAQoB,cAAgBmD,UAAUC,WAAW,eAAgBP,EAC/D,MAOF,SAAyBF,GACvBxB,OACGkC,MAAM,eAAgB,CACrBC,OAAQ,OACRC,QAAS,CACP,eAAgBZ,EAChB,eAAgB,oBAElBa,KAAMT,KAAKC,UAAU,CAAEC,YAAarE,EAAQC,QAC5C4E,YAAa,cACbC,WAAW,IACX,OACK,SAACC,GAAK,OAAKC,QAAQD,MAAMA,EAAM,GAC1C,CAnBIE,CAAgBlB,GAGlB/D,EAAQC,MAAQ,EAnBsB,CAoBxC,CAzJAsC,OAAOjB,oBAAsBA,C","file":"js/feedEvents-be21a3e00d77433d80b5.chunk.js","sourcesContent":["const MAX_BATCH_SIZE = 20; // Maybe adjust?\nconst AUTOSEND_PERIOD = 5 * 1000;\nconst VISIBLE_THRESHOLD = 0.25;\n\nconst tracker = {\n queue: [],\n processInterval: null,\n observer: new IntersectionObserver(trackFeedImpressions, {\n root: null,\n rootMargin: '0px',\n threshold: VISIBLE_THRESHOLD,\n }),\n beaconEnabled: true,\n nextFeedPosition: null,\n};\nwindow.observeFeedElements = observeFeedElements;\n\n/**\n * Sets up the feed events tracker.\n * Called every time posts are inserted into the feed.\n *\n * NOTE: this module has E2E tests at `seededFlows/homeFeedFlows/events.spec.js`\n */\nexport function observeFeedElements() {\n const feedContainer = document.getElementById('index-container');\n // Default container for Preact-rendered home feed\n const feedItemsRoot = document.getElementById('rendered-article-feed');\n\n if (!(feedContainer && feedItemsRoot)) return;\n\n const { feedCategoryClick, feedCategoryImpression, feedContextType } =\n feedContainer.dataset;\n\n // Reset all relevant state\n tracker.categoryClick = feedCategoryClick;\n tracker.categoryImpression = feedCategoryImpression;\n tracker.contextType = feedContextType;\n tracker.processInterval ||= setInterval(submitEventsBatch, AUTOSEND_PERIOD);\n tracker.observer.disconnect();\n tracker.nextFeedPosition = 1;\n\n findAndTrackFeedItems(feedItemsRoot);\n ensureQueueIsClearedBeforeExit();\n}\n\n/**\n * Given how often it may be called, and the need to assign the correct positions\n * in the feed, we take a more efficient approach to finding feed items than\n * querying the entire DOM.\n * This manual recursion (and a good chunk of `useListNavigation.js` would be\n * unnecessary if `initScrolling.js` is updated to *not* create a waterfall of elements.\n * @param {HTMLElement} root The (current) element with feed items as children.\n */\nfunction findAndTrackFeedItems(root) {\n Array.from(root.children).forEach((/** @type HTMLElement */ element) => {\n if (element.classList.contains('paged-stories')) {\n // This was inserted by `initScrolling, and will contain feed items within.\n findAndTrackFeedItems(element);\n } else if (element.dataset?.feedContentId) {\n element.dataset.feedPosition = tracker.nextFeedPosition;\n // Also captures right-click opens\n element.addEventListener('mousedown', trackFeedClickListener, true);\n tracker.observer.observe(element);\n\n tracker.nextFeedPosition += 1;\n }\n });\n}\n\n/**\n * Attempts to send any pending queued events before state is lost - e.g. when\n * navigating to a different page, or (on mobile) switching to a different app\n * (which can eventually cause the browser tab to be discarded in the background\n * at the operating system's discretion).\n * Both the unload event and the page visibility API are used as each one covers\n * some gaps that the other does not.\n */\nfunction ensureQueueIsClearedBeforeExit() {\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState == 'hidden') submitEventsBatch();\n });\n window.addEventListener('beforeunload', submitEventsBatch);\n}\n\n/**\n * Collects feed impressions, counted as at least a quarter of the article card\n * coming into view. This is typically enough to at least see the title and/or\n * a significant portion of the cover image.\n * @param {IntersectionObserverEntry[]} entries\n */\nfunction trackFeedImpressions(entries) {\n entries.forEach((entry) => {\n // At least a quarter of the card is in view; not quite enough to read the\n // title for many articles, but it'll do\n if (entry.isIntersecting && entry.intersectionRatio >= VISIBLE_THRESHOLD) {\n queueMicrotask(() => {\n const post = entry.target;\n if (!post.dataset.impressionRecorded) {\n queueEvent(post, tracker.categoryImpression);\n post.dataset.impressionRecorded = true;\n }\n });\n }\n });\n}\n\n/**\n * Sends click events to the server immediately along with any currently-batched\n * events.\n * These may not necessarily be clicks that open the article (e.g. the user may\n * have clicked on the author's profile image).\n * TODO: Only track link-opening clicks instead?\n * @param {MouseEvent} event\n */\nfunction trackFeedClickListener(event) {\n const post = event.currentTarget;\n\n if (!post.dataset.clickRecorded) {\n queueEvent(post, tracker.categoryClick);\n post.dataset.clickRecorded = true;\n submitEventsBatch();\n }\n}\n\nfunction queueEvent(post, category) {\n const { feedContentId, feedPosition } = post.dataset;\n\n tracker.queue.push({\n article_id: feedContentId,\n article_position: feedPosition,\n category,\n context_type: tracker.contextType,\n });\n\n if (tracker.queue.length >= MAX_BATCH_SIZE) {\n submitEventsBatch();\n }\n}\n\n/**\n * Sends a batch of feed events to the server.\n * Note: requests made with `navigator.sendBeacon` have greater guarantees to\n * actually complete than regular fetch requests with the `keepalive` property\n * set. However, the former is a bit tedious to implement and is often\n * inadvertently blocked by users (e.g. via extensions like uBlock). So a fallback\n * to `fetch` is included to cover that.\n */\nfunction submitEventsBatch() {\n if (tracker.queue.length === 0) return;\n\n const tokenMeta = document.querySelector(\"meta[name='csrf-token']\");\n const authenticity_token = tokenMeta?.getAttribute('content');\n\n if (tracker.beaconEnabled) {\n // The Beacon API doesn't actually let you set headers, so we set the content\n // type and CSRF token within the body itself (the browser will recognise the\n // former, and Rails will recognise the latter)\n const data = new Blob(\n [JSON.stringify({ authenticity_token, feed_events: tracker.queue })],\n { type: 'application/json' },\n );\n // `sendBeacon` returns true if sending a beacon worked, and false otherwise.\n tracker.beaconEnabled = navigator.sendBeacon('/feed_events', data);\n } else {\n fallbackRequest(authenticity_token);\n }\n\n tracker.queue = [];\n}\n\nfunction fallbackRequest(authenticity_token) {\n window\n .fetch('/feed_events', {\n method: 'POST',\n headers: {\n 'X-CSRF-Token': authenticity_token,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ feed_events: tracker.queue }),\n credentials: 'same-origin',\n keepalive: true,\n })\n .catch((error) => console.error(error));\n}\n"],"sourceRoot":""}