PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | modules/likes/queuehandler.js +526 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,526 @@
1 +/* global wpcom_reblog */
2 +
3 +var jetpackLikesWidgetBatch = [];
4 +var jetpackLikesMasterReady = false;
5 +
6 +// Due to performance problems on pages with a large number of widget iframes that need to be loaded,
7 +// we are limiting the processing at any instant to unloaded widgets that are currently in viewport,
8 +// plus this constant that will allow processing of widgets above and bellow the current fold.
9 +// This aim of it is to improve the UX and hide the transition from unloaded to loaded state from users.
10 +var jetpackLikesLookAhead = 2000; // pixels
11 +
12 +// Keeps track of loaded comment likes widget so we can unload them when they are scrolled out of view.
13 +var jetpackCommentLikesLoadedWidgets = [];
14 +
15 +var jetpackLikesDocReadyPromise = new Promise( resolve => {
16 + if ( document.readyState !== 'loading' ) {
17 + resolve();
18 + } else {
19 + window.addEventListener( 'DOMContentLoaded', () => resolve() );
20 + }
21 +} );
22 +
23 +function JetpackLikesPostMessage( message, target ) {
24 + if ( typeof message === 'string' ) {
25 + try {
26 + message = JSON.parse( message );
27 + } catch {
28 + return;
29 + }
30 + }
31 +
32 + if ( target && typeof target.postMessage === 'function' ) {
33 + try {
34 + target.postMessage(
35 + JSON.stringify( {
36 + type: 'likesMessage',
37 + data: message,
38 + } ),
39 + '*'
40 + );
41 + } catch {
42 + // Ignore error
43 + }
44 + }
45 +}
46 +
47 +function JetpackLikesBatchHandler() {
48 + const requests = [];
49 + document.querySelectorAll( 'div.jetpack-likes-widget-unloaded' ).forEach( widget => {
50 + if ( jetpackLikesWidgetBatch.indexOf( widget.id ) > -1 ) {
51 + return;
52 + }
53 +
54 + if ( ! jetpackIsScrolledIntoView( widget ) ) {
55 + return;
56 + }
57 +
58 + jetpackLikesWidgetBatch.push( widget.id );
59 +
60 + var regex = /like-(post|comment)-wrapper-(\d+)-(\d+)-(\w+)/,
61 + match = regex.exec( widget.id ),
62 + info;
63 +
64 + if ( ! match || match.length !== 5 ) {
65 + return;
66 + }
67 +
68 + info = {
69 + blog_id: match[ 2 ],
70 + width: widget.width,
71 + };
72 +
73 + if ( 'post' === match[ 1 ] ) {
74 + info.post_id = match[ 3 ];
75 + } else if ( 'comment' === match[ 1 ] ) {
76 + info.comment_id = match[ 3 ];
77 + }
78 +
79 + info.obj_id = match[ 4 ];
80 +
81 + requests.push( info );
82 + } );
83 +
84 + if ( requests.length > 0 ) {
85 + JetpackLikesPostMessage(
86 + { event: 'initialBatch', requests: requests },
87 + window.frames[ 'likes-master' ]
88 + );
89 + }
90 +}
91 +
92 +function JetpackLikesMessageListener( event ) {
93 + let message = event && event.data;
94 + if ( typeof message === 'string' ) {
95 + try {
96 + message = JSON.parse( message );
97 + } catch {
98 + return;
99 + }
100 + }
101 +
102 + const type = message && message.type;
103 + const data = message && message.data;
104 +
105 + if ( type !== 'likesMessage' || typeof data.event === 'undefined' ) {
106 + return;
107 + }
108 +
109 + // We only allow messages from one origin
110 + const allowedOrigin = 'https://widgets.wp.com';
111 + if ( allowedOrigin !== event.origin ) {
112 + return;
113 + }
114 +
115 + switch ( data.event ) {
116 + case 'masterReady':
117 + jetpackLikesDocReadyPromise.then( () => {
118 + jetpackLikesMasterReady = true;
119 +
120 + const stylesData = {
121 + event: 'injectStyles',
122 + };
123 + const sdTextColor = document.querySelector( '.sd-text-color' );
124 + const sdLinkColor = document.querySelector( '.sd-link-color' );
125 + const sdTextColorStyles = ( sdTextColor && getComputedStyle( sdTextColor ) ) || {};
126 + const sdLinkColorStyles = ( sdLinkColor && getComputedStyle( sdLinkColor ) ) || {};
127 +
128 + // enable reblogs if we're on a single post page
129 + if ( document.body.classList.contains( 'single' ) ) {
130 + JetpackLikesPostMessage( { event: 'reblogsEnabled' }, window.frames[ 'likes-master' ] );
131 + }
132 +
133 + stylesData.textStyles = {
134 + color: sdTextColorStyles.color,
135 + fontFamily: sdTextColorStyles[ 'font-family' ],
136 + fontSize: sdTextColorStyles[ 'font-size' ],
137 + direction: sdTextColorStyles.direction,
138 + fontWeight: sdTextColorStyles[ 'font-weight' ],
139 + fontStyle: sdTextColorStyles[ 'font-style' ],
140 + textDecoration: sdTextColorStyles[ 'text-decoration' ],
141 + };
142 +
143 + stylesData.linkStyles = {
144 + color: sdLinkColorStyles.color,
145 + fontFamily: sdLinkColorStyles[ 'font-family' ],
146 + fontSize: sdLinkColorStyles[ 'font-size' ],
147 + textDecoration: sdLinkColorStyles[ 'text-decoration' ],
148 + fontWeight: sdLinkColorStyles[ 'font-weight' ],
149 + fontStyle: sdLinkColorStyles[ 'font-style' ],
150 + };
151 +
152 + JetpackLikesPostMessage( stylesData, window.frames[ 'likes-master' ] );
153 +
154 + JetpackLikesBatchHandler();
155 + } );
156 +
157 + break;
158 +
159 + // We're keeping this for planned future follow ups.
160 + // @see: https://github.com/Automattic/jetpack/pull/42361#discussion_r1995338815
161 + case 'showLikeWidget':
162 + break;
163 +
164 + // We're keeping this for planned future follow ups.
165 + // @see: https://github.com/Automattic/jetpack/pull/42361#discussion_r1995338815
166 + case 'showCommentLikeWidget':
167 + break;
168 +
169 + case 'killCommentLikes':
170 + // If kill switch for comment likes is enabled remove all widgets wrappers and `Loading...` placeholders.
171 + document
172 + .querySelectorAll( '.jetpack-comment-likes-widget-wrapper' )
173 + .forEach( wrapper => wrapper.remove() );
174 + break;
175 +
176 + case 'clickReblogFlair':
177 + if ( wpcom_reblog && typeof wpcom_reblog.toggle_reblog_box_flair === 'function' ) {
178 + wpcom_reblog.toggle_reblog_box_flair( data.obj_id );
179 + }
180 + break;
181 +
182 + case 'hideOtherGravatars': {
183 + hideLikersPopover();
184 + break;
185 + }
186 +
187 + case 'showOtherGravatars': {
188 + const container = document.querySelector( '#likes-other-gravatars' );
189 +
190 + if ( ! container ) {
191 + break;
192 + }
193 +
194 + const list = container.querySelector( 'ul' );
195 +
196 + container.style.display = 'none';
197 + list.innerHTML = '';
198 +
199 + container
200 + .querySelectorAll( '.likes-text span' )
201 + .forEach( item => ( item.textContent = data.totalLikesLabel ) );
202 +
203 + ( data.likers || [] ).forEach( async ( liker, index ) => {
204 + if ( liker.profile_URL.substr( 0, 4 ) !== 'http' ) {
205 + // We only display gravatars with http or https schema
206 + return;
207 + }
208 +
209 + const element = document.createElement( 'li' );
210 + list.append( element );
211 +
212 + const profileLink = encodeURI( liker.profile_URL );
213 + const avatarLink = encodeURI( liker.avatar_URL );
214 + element.innerHTML = `<a href="${ profileLink }" rel="nofollow" target="_parent" class="wpl-liker">
215 + <img src="${ avatarLink }"
216 + alt=""
217 + style="width: 28px; height: 28px;" />
218 + <span></span>
219 + </a>`;
220 +
221 + // Add some extra attributes through native methods, to ensure strings are sanitized.
222 + element.classList.add( liker.css_class );
223 + element.querySelector( 'img' ).alt = data.avatarAltTitle.replace( '%s', liker.name );
224 + element.querySelector( 'span' ).innerText = liker.name;
225 +
226 + if ( index === data.likers.length - 1 ) {
227 + element.addEventListener( 'keydown', e => {
228 + if ( e.key === 'Tab' && ! e.shiftKey ) {
229 + e.preventDefault();
230 + hideLikersPopover();
231 +
232 + JetpackLikesPostMessage(
233 + { event: 'focusLikesCount', parent: data.parent },
234 + window.frames[ 'likes-master' ]
235 + );
236 + }
237 + } );
238 + }
239 + } );
240 +
241 + const positionPopup = function () {
242 + const containerStyle = getComputedStyle( container );
243 + const isRtl = containerStyle.direction === 'rtl';
244 +
245 + const el = document.querySelector( `*[name='${ data.parent }']` );
246 + const rect = el.getBoundingClientRect();
247 + const win = el.ownerDocument.defaultView;
248 + const offset = {
249 + top: rect.top + win.pageYOffset,
250 + left: rect.left + win.pageXOffset,
251 + };
252 +
253 + let containerLeft;
254 + container.style.top = offset.top + data.position.top - 1 + 'px';
255 +
256 + if ( isRtl ) {
257 + const visibleAvatarsCount = data && data.likers ? Math.min( data.likers.length, 5 ) : 0;
258 + // 24px is the width of the avatar + 4px is the padding between avatars
259 + containerLeft = offset.left + data.position.left + 24 * visibleAvatarsCount + 4;
260 + container.style.transform = 'translateX(-100%)';
261 + } else {
262 + containerLeft = offset.left + data.position.left;
263 + }
264 + container.style.left = containerLeft + 'px';
265 +
266 + // If the popup overflows viewport width, we should show it on the next line.
267 + // Push it offscreen to calculated rendered width.
268 + container.style.left = '-9999px';
269 + container.style.display = 'block';
270 +
271 + // If the popup exceeds the viewport width,
272 + // flip the position of the popup.
273 + const containerWidth = container.offsetWidth;
274 + const containerRight = containerLeft + containerWidth;
275 + if ( containerRight > win.innerWidth ) {
276 + containerLeft = rect.right - containerWidth;
277 + }
278 +
279 + // Set the container left
280 + container.style.left = containerLeft + 'px';
281 + container.setAttribute( 'aria-hidden', 'false' );
282 + };
283 +
284 + positionPopup();
285 + container.focus();
286 +
287 + const debounce = function ( func, wait ) {
288 + var timeout;
289 + return function () {
290 + var context = this;
291 + var args = arguments;
292 + clearTimeout( timeout );
293 + timeout = setTimeout( function () {
294 + func.apply( context, args );
295 + }, wait );
296 + };
297 + };
298 +
299 + const debouncedPositionPopup = debounce( positionPopup, 100 );
300 +
301 + // Keep a reference of this function in the element itself
302 + // so that we can destroy it later
303 + container.__resizeHandler = debouncedPositionPopup;
304 +
305 + // When window is resized, resize the popup.
306 + window.addEventListener( 'resize', debouncedPositionPopup );
307 +
308 + container.focus();
309 + }
310 + }
311 +}
312 +
313 +window.addEventListener( 'message', JetpackLikesMessageListener );
314 +
315 +function hideLikersPopover() {
316 + const container = document.querySelector( '#likes-other-gravatars' );
317 +
318 + if ( container ) {
319 + container.style.display = 'none';
320 + container.setAttribute( 'aria-hidden', 'true' );
321 +
322 + // Remove the resize event listener and cleanup.
323 + const resizeHandler = container.__resizeHandler;
324 + if ( resizeHandler ) {
325 + window.removeEventListener( 'resize', resizeHandler );
326 + delete container.__resizeHandler;
327 + }
328 + }
329 +}
330 +
331 +document.addEventListener( 'click', hideLikersPopover );
332 +
333 +function JetpackLikesWidgetQueueHandler() {
334 + var wrapperID;
335 +
336 + if ( ! jetpackLikesMasterReady ) {
337 + // The master iframe emits `masterReady` a single time, when it finishes loading. On
338 + // script-heavy pages it can finish loading — and emit — before this script attaches its
339 + // message listener above, so that one event is missed and the queue never starts. Ping the
340 + // master iframe while we wait so it re-emits `masterReady` once both it and our listener
341 + // are ready.
342 + JetpackLikesPostMessage( { event: 'queryMasterReady' }, window.frames[ 'likes-master' ] );
343 + setTimeout( JetpackLikesWidgetQueueHandler, 500 );
344 + return;
345 + }
346 +
347 + // Restore widgets to initial unloaded state when they are scrolled out of view.
348 + jetpackUnloadScrolledOutWidgets();
349 +
350 + jetpackObserveUnloadedWidgets();
351 +
352 + var unloadedWidgetsInView = jetpackGetUnloadedWidgetsInView();
353 +
354 + if ( unloadedWidgetsInView.length > 0 ) {
355 + // Grab any unloaded widgets for a batch request
356 + JetpackLikesBatchHandler();
357 + }
358 +
359 + for ( var i = 0, length = unloadedWidgetsInView.length; i <= length - 1; i++ ) {
360 + wrapperID = unloadedWidgetsInView[ i ].id;
361 +
362 + if ( ! wrapperID ) {
363 + continue;
364 + }
365 +
366 + jetpackLoadLikeWidgetIframe( wrapperID );
367 + }
368 +}
369 +
370 +function jetpackLoadLikeWidgetIframe( wrapperID ) {
371 + if ( typeof wrapperID === 'undefined' ) {
372 + return;
373 + }
374 +
375 + const wrapper = document.querySelector( '#' + wrapperID );
376 + wrapper.querySelectorAll( 'iframe' ).forEach( iFrame => iFrame.remove() );
377 +
378 + const placeholder = wrapper.querySelector( '.likes-widget-placeholder' );
379 +
380 + // Post like iframe
381 + if ( placeholder && placeholder.classList.contains( 'post-likes-widget-placeholder' ) ) {
382 + const postLikesFrame = document.createElement( 'iframe' );
383 +
384 + postLikesFrame.classList.add( 'post-likes-widget', 'jetpack-likes-widget' );
385 + postLikesFrame.name = wrapper.dataset.name;
386 + postLikesFrame.src = wrapper.dataset.src;
387 + postLikesFrame.height = '55px';
388 + postLikesFrame.width = '100%';
389 + postLikesFrame.frameBorder = '0';
390 + postLikesFrame.scrolling = 'no';
391 + postLikesFrame.title = wrapper.dataset.title;
392 +
393 + placeholder.after( postLikesFrame );
394 + }
395 +
396 + // Comment like iframe
397 + if ( placeholder.classList.contains( 'comment-likes-widget-placeholder' ) ) {
398 + const commentLikesFrame = document.createElement( 'iframe' );
399 +
400 + commentLikesFrame.class = 'comment-likes-widget-frame jetpack-likes-widget-frame';
401 + commentLikesFrame.name = wrapper.dataset.name;
402 + commentLikesFrame.src = wrapper.dataset.src;
403 + commentLikesFrame.height = '18px';
404 + commentLikesFrame.width = '100%';
405 + commentLikesFrame.frameBorder = '0';
406 + commentLikesFrame.scrolling = 'no';
407 +
408 + wrapper.querySelector( '.comment-like-feedback' ).after( commentLikesFrame );
409 +
410 + jetpackCommentLikesLoadedWidgets.push( commentLikesFrame );
411 + }
412 +
413 + wrapper.classList.remove( 'jetpack-likes-widget-unloaded' );
414 + wrapper.classList.add( 'jetpack-likes-widget-loading' );
415 +
416 + wrapper.querySelector( 'iframe' ).addEventListener( 'load', e => {
417 + // A widget scrolled out of view mid-load has its iframe dropped, and a reloaded one
418 + // replaces it, either of which leaves this closure holding a detached iframe. Acting on
419 + // it would hide a placeholder that is now the only thing the widget has left to show.
420 + if ( ! wrapper.contains( e.target ) ) {
421 + return;
422 + }
423 +
424 + JetpackLikesPostMessage(
425 + { event: 'loadLikeWidget', name: e.target.name, width: e.target.width },
426 + window.frames[ 'likes-master' ]
427 + );
428 +
429 + // The stylesheet is otherwise the only thing that hides the placeholder, so one that never
430 + // applies leaves "Loading…" sitting over a widget that loaded fine.
431 + if ( placeholder ) {
432 + placeholder.style.display = 'none';
433 + }
434 +
435 + wrapper.classList.remove( 'jetpack-likes-widget-loading' );
436 + wrapper.classList.add( 'jetpack-likes-widget-loaded' );
437 + } );
438 +}
439 +
440 +function jetpackGetUnloadedWidgetsInView() {
441 + const unloadedWidgets = document.querySelectorAll( 'div.jetpack-likes-widget-unloaded' );
442 +
443 + return [ ...unloadedWidgets ].filter( item => jetpackIsScrolledIntoView( item ) );
444 +}
445 +
446 +function jetpackIsScrolledIntoView( element ) {
447 + const top = element.getBoundingClientRect().top;
448 + const bottom = element.getBoundingClientRect().bottom;
449 +
450 + // Allow some slack above and bellow the fold with jetpackLikesLookAhead,
451 + // with the aim of hiding the transition from unloaded to loaded widget from users.
452 + return top + jetpackLikesLookAhead >= 0 && bottom <= window.innerHeight + jetpackLikesLookAhead;
453 +}
454 +
455 +function jetpackUnloadScrolledOutWidgets() {
456 + for ( let i = jetpackCommentLikesLoadedWidgets.length - 1; i >= 0; i-- ) {
457 + const currentWidgetIframe = jetpackCommentLikesLoadedWidgets[ i ];
458 +
459 + if ( ! jetpackIsScrolledIntoView( currentWidgetIframe ) ) {
460 + const widgetWrapper =
461 + currentWidgetIframe &&
462 + currentWidgetIframe.parentElement &&
463 + currentWidgetIframe.parentElement.parentElement;
464 +
465 + // Restore parent class to 'unloaded' so this widget can be picked up by queue manager again if needed.
466 + widgetWrapper.classList.remove( 'jetpack-likes-widget-loaded' );
467 + widgetWrapper.classList.remove( 'jetpack-likes-widget-loading' );
468 + widgetWrapper.classList.add( 'jetpack-likes-widget-unloaded' );
469 +
470 + // An empty string removes the inline declaration rather than setting one, handing the
471 + // placeholder back to the stylesheet. The `display: none` set on load would otherwise
472 + // outrank it, leaving an unloaded widget with neither an iframe nor a placeholder.
473 + const placeholder = widgetWrapper.querySelector( '.likes-widget-placeholder' );
474 + if ( placeholder ) {
475 + placeholder.style.display = '';
476 + }
477 +
478 + // Remove it from the list of loaded widgets.
479 + jetpackCommentLikesLoadedWidgets.splice( i, 1 );
480 +
481 + // Remove comment like widget iFrame.
482 + currentWidgetIframe.remove();
483 + }
484 + }
485 +}
486 +
487 +var jetpackWidgetsDelayedExec = function ( after, fn ) {
488 + var timer;
489 + return function () {
490 + clearTimeout( timer );
491 + timer = setTimeout( fn, after );
492 + };
493 +};
494 +
495 +var jetpackOnScrollStopped = jetpackWidgetsDelayedExec( 250, JetpackLikesWidgetQueueHandler );
496 +
497 +// Scrolling is not the only thing that brings a widget into range. A stylesheet that lands late
498 +// reflows the page without firing a scroll event, and the queue would never look at that widget
499 +// again, leaving it on "Loading…" until the reader happens to scroll.
500 +var jetpackLikesWidgetObserver =
501 + typeof IntersectionObserver === 'function'
502 + ? new IntersectionObserver( jetpackOnScrollStopped, {
503 + rootMargin: `${ jetpackLikesLookAhead }px`,
504 + // jetpackIsScrolledIntoView() wants the widget fully inside the band, so ask to be
505 + // told when it gets there - crossing into partial overlap alone would not load it.
506 + threshold: [ 0, 1 ],
507 + } )
508 + : null;
509 +
510 +// Observing an element twice is a no-op, so every queue pass can call this to pick up widgets
511 +// added after load.
512 +function jetpackObserveUnloadedWidgets() {
513 + if ( ! jetpackLikesWidgetObserver ) {
514 + return;
515 + }
516 +
517 + document
518 + .querySelectorAll( 'div.jetpack-likes-widget-unloaded' )
519 + .forEach( widget => jetpackLikesWidgetObserver.observe( widget ) );
520 +}
521 +
522 +// Load initial batch of widgets, prior to any scrolling events.
523 +JetpackLikesWidgetQueueHandler();
524 +
525 +// Add event listener to execute queue handler after scroll.
526 +window.addEventListener( 'scroll', jetpackOnScrollStopped, true );