1/* global DOMPurify */ 2(function () { 3 /** 4 * @typedef {Object} TalkingPointsApi 5 * @property {string=} publicUrl 6 * @property {string=} fallbackAvatarUrl 7 * @property {string=} likeIconUrl 8 */ 9 10 /** @type {TalkingPointsApi} */ 11 const api = window.TalkingPointsApi || {}; 12 const baseUrl = api.publicUrl; 13 if (!baseUrl) return; 14 15 function sanitizeUserHtml(value) { 16 if (!value) { 17 return false; 18 } 19 20 if (typeof DOMPurify === 'undefined') { 21 return false; 22 } 23 24 if (!DOMPurify.isSupported) { 25 return false; 26 } 27 28 return DOMPurify.sanitize(value); 29 } 30 31 function buildFeaturedItem(author, body, likes) { 32 const itemContent = document.createElement('div'); 33 itemContent.className = 'tp-featured-item'; 34 35 const header = document.createElement('div'); 36 header.className = 'tp-featured-header'; 37 38 const authorWrap = document.createElement('div'); 39 authorWrap.className = 'tp-featured-author'; 40 41 let userAvatar = window.TalkingPointsApi?.publicAvatarUrl ?? ''; 42 userAvatar = userAvatar.replace('{username}', author); 43 44 const avatar = document.createElement('img'); 45 avatar.className = 'tp-featured-avatar'; 46 avatar.setAttribute('alt', ''); 47 avatar.setAttribute('loading', 'lazy'); 48 avatar.setAttribute('decoding', 'async'); 49 avatar.width = 30; 50 avatar.height = 30; 51 avatar.src = userAvatar || api.fallbackAvatarUrl || ''; 52 53 if (!userAvatar) { 54 avatar.classList.add('fallback'); 55 } 56 57 const name = document.createElement('span'); 58 name.className = 'tp-featured-name'; 59 name.textContent = author || 'Anonymous'; 60 61 authorWrap.appendChild(avatar); 62 authorWrap.appendChild(name); 63 64 const featuredTag = document.createElement('div'); 65 featuredTag.className = 'tp-featured-tag'; 66 featuredTag.textContent = 'Featured comment'; 67 68 const bodyElement = document.createElement('p'); 69 bodyElement.className = 'tp-featured-body'; 70 71 const sanitized = sanitizeUserHtml(body); 72 73 if (sanitized) { 74 bodyElement.innerHTML = sanitized; 75 } else { 76 bodyElement.textContent = body; 77 } 78 79 const actions = document.createElement('div'); 80 actions.className = 'tp-featured-actions'; 81 82 if (likes > 0) { 83 const like = document.createElement('span'); 84 like.className = 'tp-featured-like'; 85 86 const icon = document.createElement('img'); 87 icon.className = 'tp-featured-like-icon'; 88 icon.setAttribute('alt', ''); 89 icon.setAttribute('aria-hidden', 'true'); 90 icon.width = 16; 91 icon.height = 16; 92 icon.src = api.likeIconUrl || ''; 93 94 const count = document.createElement('span'); 95 count.className = 'tp-featured-like-count'; 96 count.textContent = String(likes); 97 98 like.appendChild(icon); 99 like.appendChild(count); 100 actions.appendChild(like); 101 } 102 103 header.appendChild(authorWrap); 104 header.appendChild(featuredTag); 105 itemContent.appendChild(header); 106 itemContent.appendChild(bodyElement); 107 108 if (actions.childNodes.length) { 109 itemContent.appendChild(actions); 110 } 111 112 return itemContent; 113 } 114 115 function getTrackGapPx(track) { 116 const computedStyle = window.getComputedStyle(track); 117 return parseFloat(computedStyle.columnGap || computedStyle.gap || 0) || 0; 118 } 119 120 function updateDotsActiveState(dots, activeIndex) { 121 for (let dotIndex = 0; dotIndex < dots.length; dotIndex++) { 122 const dot = dots[dotIndex]; 123 if (!dot) { 124 continue; 125 } 126 127 if (dotIndex === activeIndex) { 128 dot.setAttribute('aria-current', 'true'); 129 dot.classList.add('is-active'); 130 } else { 131 dot.removeAttribute('aria-current'); 132 dot.classList.remove('is-active'); 133 } 134 } 135 } 136 137 function setTrackOffset(track, slides, activeIndex) { 138 const firstSlide = slides[0]; 139 const slideWidth = firstSlide ? firstSlide.offsetWidth : 0; 140 const gapPx = getTrackGapPx(track); 141 track.style.marginLeft = String(-(activeIndex * (slideWidth + gapPx))) + 'px'; 142 } 143 144 function setSlideWidths(slides, width) { 145 for (let slideIndex = 0; slideIndex < slides.length; slideIndex++) { 146 slides[slideIndex].style.width = String(width) + 'px'; 147 } 148 } 149 150 function updateCarouselHeight(carousel, slides) { 151 let maxHeight = 0; 152 153 for (let slideIndex = 0; slideIndex < slides.length; slideIndex++) { 154 const height = slides[slideIndex].offsetHeight; 155 if (height > maxHeight) { 156 maxHeight = height; 157 } 158 } 159 160 if (maxHeight) { 161 carousel.style.height = String(maxHeight) + 'px'; 162 } 163 } 164 165 function createCarouselDom(items) { 166 const carousel = document.createElement('div'); 167 carousel.className = 'tp-carousel'; 168 carousel.setAttribute('role', 'region'); 169 carousel.setAttribute('aria-label', 'Featured comments'); 170 carousel.setAttribute('aria-live', 'polite'); 171 172 const track = document.createElement('div'); 173 track.className = 'tp-track'; 174 175 const slides = []; 176 const dots = []; 177
178 const dotsWrap = items.length > 1 ? document.createElement('div') : null; 179 if (dotsWrap) { 180 dotsWrap.className = 'tp-dots'; 181 carousel.classList.add('has-dots'); 182 } 183 184 for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { 185 const item = items[itemIndex]; 186 187 const slide = document.createElement('div'); 188 slide.className = 'tp-slide'; 189 slide.appendChild(item); 190 191 slides.push(slide); 192 track.appendChild(slide); 193 194 if (!dotsWrap) { 195 continue; 196 } 197 198 const dot = document.createElement('button'); 199 dot.type = 'button'; 200 dot.className = 'tp-dot'; 201 dot.setAttribute('aria-label', 'Go to comment ' + String(itemIndex + 1)); 202 203 dots.push(dot); 204 dotsWrap.appendChild(dot); 205 } 206 207 carousel.appendChild(track); 208 209 if (dotsWrap) { 210 carousel.appendChild(dotsWrap); 211 } 212 213 return { 214 carousel: carousel, 215 track: track, 216 slides: slides, 217 dots: dots, 218 dotsWrap: dotsWrap 219 }; 220 } 221 222 function attachCarouselBehavior(dom) { 223 const slides = dom.slides; 224 const dots = dom.dots; 225 226 let activeIndex = 0; 227 let timerId = null; 228 let layoutScheduled = false; 229 230 function goTo(nextIndex) { 231 if (!slides.length) { 232 return; 233 } 234 235 activeIndex = (nextIndex + slides.length) % slides.length; 236 237 setTrackOffset(dom.track, slides, activeIndex); 238 updateDotsActiveState(dots, activeIndex); 239 } 240 241 function stop() { 242 if (!timerId) { 243 return; 244 } 245 246 clearInterval(timerId); 247 timerId = null; 248 } 249 250 function start() { 251 stop(); 252 timerId = window.setInterval(function () { 253 goTo(activeIndex + 1); 254 }, 5000); 255 } 256 257 function layout() { 258 layoutScheduled = false; 259 260 const width = dom.carousel.getBoundingClientRect().width; 261 setSlideWidths(slides, width); 262 updateCarouselHeight(dom.carousel, slides); 263 goTo(activeIndex); 264 } 265 266 function scheduleLayout() { 267 if (layoutScheduled) { 268 return; 269 } 270 271 layoutScheduled = true; 272 273 requestAnimationFrame(function () { 274 requestAnimationFrame(layout); 275 }); 276 } 277 278 function onMouseEnter() { 279 stop(); 280 } 281 282 function onMouseLeave() { 283 if (slides.length > 1) { 284 start(); 285 } 286 } 287 288 dom.carousel.addEventListener('mouseenter', onMouseEnter); 289 dom.carousel.addEventListener('mouseleave', onMouseLeave); 290 291 for (let dotIndex = 0; dotIndex < dots.length; dotIndex++) { 292 const dot = dots[dotIndex]; 293 dot.addEventListener('click', function (event) { 294 event.preventDefault(); 295 event.stopPropagation(); 296 297 goTo(dotIndex); 298 start(); 299 }); 300 } 301 302 if (window.ResizeObserver) { 303 const resizeObserver = new ResizeObserver(function () { 304 scheduleLayout(); 305 }); 306 307 resizeObserver.observe(dom.carousel); 308 } else { 309 window.addEventListener('resize', function () { 310 scheduleLayout(); 311 }); 312 } 313 314 scheduleLayout(); 315 goTo(0); 316 317 if (slides.length > 1) { 318 start(); 319 } 320 321 return { 322 element: dom.carousel, 323 goTo: goTo, 324 start: start, 325 stop: stop 326 }; 327 } 328 329 function createCarouselController(items) { 330 const dom = createCarouselDom(items); 331 return attachCarouselBehavior(dom); 332 } 333 334 function requestFeaturedReplies(commentId) { 335 const fullUrl = new URL(`comment/${encodeURIComponent(commentId)}/replies/featured`, baseUrl); 336 return fetch(fullUrl, { 337 method: 'GET', 338 headers: { 'content-type': 'application/json' } 339 }).then(function (response) { 340 if (!response.ok) { 341 throw new Error('bad'); 342 } 343 344 return response.json(); 345 }); 346 } 347 348 function renderFeaturedCarousel(slotElement, items) { 349 if (!items.length) { 350 return; 351 } 352 353 const carouselController = createCarouselController(items); 354 slotElement.appendChild(carouselController.element); 355 slotElement.removeAttribute('hidden'); 356 } 357 358 function buildFeaturedItemsFromEdges(chosenEdges) { 359 const items = []; 360 361 for (let index = 0; index < chosenEdges.length; index++) { 362 const node = chosenEdges[index] && chosenEdges[index].node; 363 if (!node) { 364 continue; 365 } 366 367 const author = node.author && node.author.username; 368 const body = node.body || ''; 369 const likes = node.actionCounts && node.actionCounts.reaction ? node.actionCounts.reaction.total : 0; 370 371 if (body.trim() !== '') { 372 items.push(buildFeaturedItem(author, body, likes)); 373 } 374 } 375 376 return items; 377 } 378 379 function getTalkingPointFeaturedContext(talkingPointElement) { 380 const commentId = talkingPointElement.getAttribute('data-comment-id') || ''; 381 const slotElement = talkingPointElement.querySelector('[data-featured-slot]'); 382 383 if (!commentId || !slotElement) { 384 return null; 385 } 386 387 return { commentId: commentId, slotElement: slotElement }; 388 } 389 390 function loadFeaturedReplies(commentId, slotElement) { 391 requestFeaturedReplies(commentId) 392 .then(function (payload) { 393 const connection = payload && payload.data && payload.data.comment && payload.data.comment.replies; 394 const edges = connection && Array.isArray(connection.edges) ? connection.edges : []; 395 396 if (!edges.length) { 397 return; 398 } 399 400 const items = buildFeaturedItemsFromEdges(edges); 401 renderFeaturedCarousel(slotElement, items); 402 }) 403 .catch(function () { 404 // Silent fail 405 }); 406 } 407 408 function initFeaturedComments(root) { 409 const scope = root || document; 410 const talkingPointElements = scope.querySelectorAll('.grv-talking-point[data-comment-id]'); 411
412 talkingPointElements.forEach(function (talkingPointElement) { 413 if (talkingPointElement.getAttribute('data-featured-disabled') === '1') { 414 return; 415 } 416 417 if (talkingPointElement.getAttribute('data-featured-initialized') === '1') { 418 return; 419 } 420 421 // No replies means no featured reply is possible; skip the fetch. 422 const knownCount = talkingPointElement.getAttribute('data-reply-count-known'); 423 if (knownCount !== null && Number(knownCount) <= 0) { 424 talkingPointElement.setAttribute('data-featured-initialized', '1'); 425 return; 426 } 427 428 const context = getTalkingPointFeaturedContext(talkingPointElement); 429 if (!context) { 430 return; 431 } 432 433 talkingPointElement.setAttribute('data-featured-initialized', '1'); 434 435 loadFeaturedReplies(context.commentId, context.slotElement); 436 }); 437 } 438 439 initFeaturedComments(); 440 441 // Dispatched by the endless-scroll feature on article pages when a new article loads. 442 document.addEventListener('infscrollpage', function () { 443 initFeaturedComments(); 444 }); 445 446 window.TPWPFeaturedComments = { init: initFeaturedComments }; 447})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.