jenkins-bot has submitted this change and it was merged.

Change subject: Render previews
......................................................................


Render previews

Extract core rendering functionality from the mw.popups.renderer and
mw.popups.renderer.article objects.

For now, render and show the preview when the user dwells on and
abandons a link respectively.

Supporting changes:
* Add mw.popups.wait, which is sugar around window.setTimeout.
* action.response -> action.result in the FETCH_END case of the preview
  reducer.

Change-Id: I14b437e7c2f55b988837fcb2800dd61a23c29a01
---
M extension.json
D resources/ext.popups.desktop/ext.popups.renderer.article.js
M resources/ext.popups.desktop/popup.mustache
D resources/ext.popups.renderer.desktopRenderer/desktopRenderer.js
D resources/ext.popups.targets.desktopTarget/desktopTarget.js
M resources/ext.popups/actions.js
M resources/ext.popups/boot.js
M resources/ext.popups/reducers.js
A resources/ext.popups/renderChangeListener.js
A resources/ext.popups/renderer.js
A resources/ext.popups/wait.js
A tests/qunit/ext.popups/wait.test.js
12 files changed, 716 insertions(+), 1,242 deletions(-)

Approvals:
  jenkins-bot: Verified
  Phuedx: Looks good to me, approved



diff --git a/extension.json b/extension.json
index d1ffca8..1b6132e 100644
--- a/extension.json
+++ b/extension.json
@@ -58,6 +58,7 @@
                "ext.popups": {
                        "scripts": [
                                "resources/ext.popups/index.js",
+                               "resources/ext.popups/wait.js",
                                "resources/ext.popups/userSettings.js",
                                "resources/ext.popups/experiment.js",
                                "resources/ext.popups/actions.js",
@@ -67,6 +68,8 @@
                                "resources/ext.popups/changeListener.js",
                                
"resources/ext.popups/footerLinkChangeListener.js",
                                
"resources/ext.popups/linkTitleChangeListener.js",
+                               "resources/ext.popups/renderer.js",
+                               "resources/ext.popups/renderChangeListener.js",
                                "resources/ext.popups/boot.js"
                        ],
                        "templates": {
@@ -74,6 +77,7 @@
                                "settings.mustache": 
"resources/ext.popups.desktop/settings.mustache"
                        },
                        "styles": [
+                               
"resources/ext.popups.core/ext.popups.core.less",
                                
"resources/ext.popups.desktop/ext.popups.animation.less",
                                
"resources/ext.popups.desktop/ext.popups.settings.less"
                        ],
diff --git a/resources/ext.popups.desktop/ext.popups.renderer.article.js 
b/resources/ext.popups.desktop/ext.popups.renderer.article.js
deleted file mode 100644
index b77f31f..0000000
--- a/resources/ext.popups.desktop/ext.popups.renderer.article.js
+++ /dev/null
@@ -1,630 +0,0 @@
-( function ( $, mw ) {
-       'use strict';
-
-       /**
-        * @class mw.popups.render.article
-        * @singleton
-        */
-       var currentRequest,
-               isSafari = navigator.userAgent.match( /Safari/ ) !== null,
-               article = {},
-               $window = $( window ),
-               CHARS = 525,
-               SIZES = {
-                       portraitImage: {
-                               h: 250, // Exact height
-                               w: 203 // Max width
-                       },
-                       landscapeImage: {
-                               h: 200, // Max height
-                               w: 300 // Exact Width
-                       },
-                       landscapePopupWidth: 450, // Exact width of a landscape 
popup
-                       portraitPopupWidth: 300, // Exact width of a portrait 
popup
-                       pokeySize: 8 // Height of the triangle used to point at 
the link
-               };
-
-       /**
-        * Send an API request and cache the jQuery element
-        *
-        * @param {jQuery} link
-        * @param {Object} logData data to be logged
-        * @return {jQuery.Promise}
-        */
-       article.init = function ( link, logData ) {
-               var href = link.attr( 'href' ),
-                       title = mw.popups.getTitle( href ),
-                       deferred = $.Deferred(),
-                       scaledThumbSize = 300 * $.bracketedDevicePixelRatio();
-
-               if ( !title ) {
-                       return deferred.reject().promise();
-               }
-
-               currentRequest = mw.popups.api.get( {
-                       action: 'query',
-                       prop: 'info|extracts|pageimages|revisions',
-                       formatversion: 2,
-                       redirects: true,
-                       exintro: true,
-                       exchars: CHARS,
-                       // there is an added geometric limit on 
.mwe-popups-extract
-                       // so that text does not overflow from the card
-                       explaintext: true,
-                       piprop: 'thumbnail',
-                       pithumbsize: scaledThumbSize,
-                       rvprop: 'timestamp',
-                       titles: title,
-                       smaxage: 300,
-                       maxage: 300,
-                       uselang: 'content'
-               }, {
-                       headers: {
-                               'X-Analytics': 'preview=1'
-                       }
-               } );
-
-               currentRequest.fail( function ( textStatus, data ) {
-                       // only log genuine errors, not client aborts
-                       if ( data.textStatus !== 'abort' ) {
-                               mw.track( 'ext.popups.event', $.extend( 
logData, {
-                                       action: 'error',
-                                       errorState: textStatus
-                               } ) );
-                       }
-                       deferred.reject();
-               } )
-               .done( function ( re ) {
-                       currentRequest = undefined;
-
-                       if (
-                               !re.query ||
-                               !re.query.pages ||
-                               !re.query.pages[ 0 ].extract ||
-                               re.query.pages[ 0 ].extract === ''
-                       ) {
-                               // Restore the title attribute and set flag
-                               if ( link.data( 'dont-empty-title' ) !== true ) 
{
-                                       link
-                                               .attr( 'title', link.data( 
'title' ) )
-                                               .removeData( 'title' )
-                                               .data( 'dont-empty-title', true 
);
-                               }
-                               deferred.reject();
-                               return;
-                       }
-
-                       re.query.pages[ 0 ].extract = removeEllipsis( 
re.query.pages[ 0 ].extract );
-
-                       mw.popups.render.cache[ href ] = {};
-                       mw.popups.render.cache[ href ].popup = 
article.createPopup( re.query.pages[ 0 ], href );
-                       mw.popups.render.cache[ href ].getOffset = 
article.getOffset;
-                       mw.popups.render.cache[ href ].getClasses = 
article.getClasses;
-                       mw.popups.render.cache[ href ].process = 
article.processPopup;
-                       deferred.resolve();
-               } );
-
-               return deferred.promise();
-       };
-
-       /**
-        * Returns a thumbnail object based on the ratio of the image
-        * Uses an SVG image where available to add the triangle/pokey
-        * mask on the image. Crops and resizes the SVG image so that
-        * is fits inside a rectangle of a particular size.
-        *
-        * @method createPopup
-        * @param {Object} page Information about the linked page
-        * @param {string} href
-        * @return {jQuery}
-        */
-       article.createPopup = function ( page, href ) {
-               var $div, hasThumbnail,
-                       thumbnail = page.thumbnail,
-                       tall = thumbnail && thumbnail.height > thumbnail.width,
-                       $thumbnail = article.createThumbnail( thumbnail, tall ),
-                       timestamp = new Date( page.revisions[ 0 ].timestamp ),
-                       timediff = new Date() - timestamp,
-                       oneDay = 1000 * 60 * 60 * 24;
-
-               // createThumbnail returns an empty <span> if there is no 
thumbnail
-               hasThumbnail = $thumbnail.prop( 'tagName' ) !== 'SPAN';
-
-               $div = mw.template.get( 'ext.popups.desktop', 'popup.mustache' 
).render( {
-                       langcode: page.pagelanguagehtmlcode,
-                       langdir: page.pagelanguagedir,
-                       href: href,
-                       isRecent: timediff < oneDay,
-                       lastModified: mw.message( 'popups-last-edited', moment( 
timestamp ).fromNow() ).text(),
-                       hasThumbnail: hasThumbnail
-               } );
-
-               // FIXME: Ideally these things should be added in template. 
These will be refactored in future patches.
-               if ( !hasThumbnail ) {
-                       tall = thumbnail = undefined;
-               } else {
-                       $div.find( '.mwe-popups-discreet' ).append( $thumbnail 
);
-               }
-               $div.find( '.mwe-popups-extract' )
-                       .append( article.getProcessedElements( page.extract, 
page.title ) );
-
-               mw.popups.render.cache[ href ].settings = {
-                       title: page.title,
-                       namespace: page.ns,
-                       tall: ( tall === undefined ) ? false : tall,
-                       thumbnail: ( thumbnail === undefined ) ? false : 
thumbnail
-               };
-
-               return $div;
-       };
-
-       /**
-        * Returns an array of elements to be appended after removing 
parentheses
-        * and making the title in the extract bold.
-        *
-        * @method getProcessedElements
-        * @param {string} extract Should be unescaped
-        * @param {string} title Should be unescaped
-        * @return {Array} of elements to appended
-        */
-       article.getProcessedElements = function ( extract, title ) {
-               var regExp, escapedTitle,
-                       elements = [],
-                       boldIdentifier = '<bi-' + Math.random() + '>',
-                       snip = '<snip-' + Math.random() + '>';
-
-               title = article.removeParensFromText( title );
-               title = title.replace( /\s+/g, ' ' ).trim(); // Remove extra 
white spaces
-               escapedTitle = mw.RegExp.escape( title ); // Escape RegExp 
elements
-               regExp = new RegExp( '(^|\\s)(' + escapedTitle + ')(|$)', 'i' );
-
-               // Remove text in parentheses along with the parentheses
-               extract = article.removeParensFromText( extract );
-               extract = extract.replace( /\s+/, ' ' ); // Remove extra white 
spaces
-
-               // Make title bold in the extract text
-               // As the extract is html escaped there can be no such string 
in it
-               // Also, the title is escaped of RegExp elements thus can't 
have "*"
-               extract = extract.replace( regExp, '$1' + snip + boldIdentifier 
+ '$2' + snip + '$3' );
-               extract = extract.split( snip );
-
-               $.each( extract, function ( index, part ) {
-                       if ( part.indexOf( boldIdentifier ) === 0 ) {
-                               elements.push( $( '<b>' ).text( part.substring( 
boldIdentifier.length ) ) );
-                       } else {
-                               elements.push( document.createTextNode( part ) 
);
-                       }
-               } );
-
-               return elements;
-       };
-
-       /**
-        * Removes content in parentheses from a string.  Returns the original
-        * string as is if the parentheses are unbalanced or out or order. Does 
not
-        * remove extra spaces.
-        *
-        * @method removeParensFromText
-        * @param {string} string
-        * @return {string}
-        */
-       article.removeParensFromText = function ( string ) {
-               var
-                       ch,
-                       newString = '',
-                       level = 0,
-                       i = 0;
-
-               for ( i; i < string.length; i++ ) {
-                       ch = string.charAt( i );
-
-                       if ( ch === ')' && level === 0  ) {
-                               return string;
-                       }
-                       if ( ch === '(' ) {
-                               level++;
-                               continue;
-                       } else if ( ch === ')' ) {
-                               level--;
-                               continue;
-                       }
-                       if ( level === 0 ) {
-                               // Remove leading spaces before brackets
-                               if ( ch === ' ' && string.charAt( i + 1 ) === 
'(' ) {
-                                       continue;
-                               }
-                               newString += ch;
-                       }
-               }
-
-               return ( level === 0 ) ? newString : string;
-       };
-
-       /**
-        * Use createElementNS to create the svg:image tag as jQuery
-        * uses createElement instead. Some browsers map the `image` tag
-        * to `img` tag, thus an `svg:image` is required.
-        *
-        * @method createSVGTag
-        * @param {string} tag
-        * @return {Object}
-        */
-       article.createSVGTag = function ( tag ) {
-               return document.createElementNS( 'http://www.w3.org/2000/svg', 
tag );
-       };
-
-       /**
-        * Returns a thumbnail object based on the ratio of the image
-        * Uses an SVG image where available to add the triangle/pokey
-        * mask on the image. Crops and resizes the SVG image so that
-        * is fits inside a rectangle of a particular size.
-        *
-        * @method createThumbnail
-        * @param {Object} thumbnail
-        * @param {boolean} tall
-        * @return {Object} jQuery DOM element of the thumbnail
-        */
-       article.createThumbnail = function ( thumbnail, tall ) {
-               var thumbWidth, thumbHeight,
-                       x, y, width, height, clipPath,
-                       devicePixelRatio = $.bracketedDevicePixelRatio();
-
-               // No thumbnail
-               if ( !thumbnail ) {
-                       return $( '<span>' );
-               }
-
-               thumbWidth = thumbnail.width / devicePixelRatio;
-               thumbHeight = thumbnail.height / devicePixelRatio;
-
-               if (
-                       // Image too small for landscape display
-                       ( !tall && thumbWidth < SIZES.landscapeImage.w ) ||
-                       // Image too small for portrait display
-                       ( tall && thumbHeight < SIZES.portraitImage.h ) ||
-                       // These characters in URL that could inject CSS and 
thus JS
-                       (
-                               thumbnail.source.indexOf( '\\' ) > -1 ||
-                               thumbnail.source.indexOf( '\'' ) > -1 ||
-                               thumbnail.source.indexOf( '\"' ) > -1
-                       )
-               ) {
-                       return $( '<span>' );
-               }
-
-               if ( tall ) {
-                       x = ( thumbWidth > SIZES.portraitImage.w ) ?
-                               ( ( thumbWidth - SIZES.portraitImage.w ) / -2 ) 
:
-                               ( SIZES.portraitImage.w - thumbWidth );
-                       y = ( thumbHeight > SIZES.portraitImage.h ) ?
-                               ( ( thumbHeight - SIZES.portraitImage.h ) / -2 
) : 0;
-                       width = SIZES.portraitImage.w;
-                       height = SIZES.portraitImage.h;
-               } else {
-                       x = 0;
-                       y = ( thumbHeight > SIZES.landscapeImage.h ) ?
-                               ( ( thumbHeight - SIZES.landscapeImage.h ) / -2 
) : 0;
-                       width = SIZES.landscapeImage.w + 3;
-                       height = ( thumbHeight > SIZES.landscapeImage.h ) ?
-                               SIZES.landscapeImage.h : thumbHeight;
-                       clipPath = 'mwe-popups-mask';
-               }
-               return article.createSvgImageThumbnail(
-                       // FIXME: Not clear why this class is always added even 
if the popup is not tall
-                       'mwe-popups-is-not-tall',
-                       thumbnail.source,
-                       x,
-                       y,
-                       thumbWidth,
-                       thumbHeight,
-                       width,
-                       height,
-                       clipPath
-               );
-       };
-
-       /**
-        * Returns the `svg:image` object for thumbnail
-        *
-        * @method createSvgImageThumbnail
-        * @param {string} className
-        * @param {string} url
-        * @param {number} x
-        * @param {number} y
-        * @param {number} thumbnailWidth
-        * @param {number} thumbnailHeight
-        * @param {number} width
-        * @param {number} height
-        * @param {string} clipPath
-        * @return {jQuery}
-        */
-       article.createSvgImageThumbnail = function (
-               className, url, x, y, thumbnailWidth, thumbnailHeight, width, 
height, clipPath
-       ) {
-               var $thumbnailSVGImage, $thumbnail,
-                       ns = 'http://www.w3.org/2000/svg',
-                       svgElement = article.createSVGTag( 'image' );
-
-               $thumbnailSVGImage = $( svgElement );
-               $thumbnailSVGImage
-                       .addClass( className )
-                       .attr( {
-                               x: x,
-                               y: y,
-                               width: thumbnailWidth,
-                               height: thumbnailHeight,
-                               'clip-path': 'url(#' + clipPath + ')'
-                       } );
-
-               // Make image render in Safari (T138430)
-               if ( isSafari ) {
-                       svgElement.setAttribute( 'xlink:href', url );
-               } else {
-                       // certain browsers e.g. ie9 will not correctly set 
attributes from foreign namespaces (T134979)
-                       svgElement.setAttributeNS( ns, 'xlink:href', url );
-               }
-               $thumbnail = $( '<svg>' )
-                       .attr( {
-                               xmlns: ns,
-                               width: width,
-                               height: height
-                       } )
-                       .append( $thumbnailSVGImage );
-
-               return $thumbnail;
-       };
-
-       /**
-        * Positions the popup based on the mouse position and popup size
-        * Default popup positioning is below and to the right of the mouse or 
link,
-        * unless flippedX or flippedY is true. flippedX and flippedY are 
cached.
-        *
-        * @method getOffset
-        * @param {jQuery} link
-        * @param {Object} event
-        * @return {Object} This can be passed to `.css()` to position the 
element
-        */
-       article.getOffset = function ( link, event ) {
-               var
-                       href = link.attr( 'href' ),
-                       flippedX = false,
-                       flippedY = false,
-                       settings = mw.popups.render.cache[ href ].settings,
-                       offsetTop = ( event.pageY ) ? // If it was a mouse event
-                               // Position according to mouse
-                               // Since client rectangles are relative to the 
viewport,
-                               // take scroll position into account.
-                               getClosestYPosition(
-                                       event.pageY - $window.scrollTop(),
-                                       link.get( 0 ).getClientRects(),
-                                       false
-                               ) + $window.scrollTop() + SIZES.pokeySize :
-                               // Position according to link position or size
-                               link.offset().top + link.height() + 
SIZES.pokeySize,
-                       clientTop = ( event.clientY ) ?
-                               event.clientY :
-                               offsetTop,
-                       offsetLeft = ( event.pageX ) ?
-                               event.pageX :
-                               link.offset().left;
-
-               // X Flip
-               if ( offsetLeft > ( $( window ).width() / 2 ) ) {
-                       offsetLeft += ( !event.pageX ) ? link.width() : 0;
-                       offsetLeft -= ( !settings.tall ) ?
-                               SIZES.portraitPopupWidth :
-                               SIZES.landscapePopupWidth;
-                       flippedX = true;
-               }
-
-               if ( event.pageX ) {
-                       offsetLeft += ( flippedX ) ? 20 : -20;
-               }
-
-               mw.popups.render.cache[ href ].settings.flippedX = flippedX;
-
-               // Y Flip
-               if ( clientTop > ( $( window ).height() / 2 ) ) {
-                       flippedY = true;
-
-                       // Change the Y position to the top of the link
-                       if ( event.pageY ) {
-                               // Since client rectangles are relative to the 
viewport,
-                               // take scroll position into account.
-                               offsetTop = getClosestYPosition(
-                                       event.pageY - $window.scrollTop(),
-                                       link.get( 0 ).getClientRects(),
-                                       true
-                               ) + $window.scrollTop() + 2 * SIZES.pokeySize;
-                       }
-               }
-
-               mw.popups.render.cache[ href ].settings.flippedY = flippedY;
-
-               return {
-                       top: offsetTop + 'px',
-                       left: offsetLeft + 'px'
-               };
-       };
-
-       /**
-        * Returns an array of classes based on the size and setting of the 
popup
-        *
-        * @method getClassses
-        * @param {jQuery} link
-        * @return {Array} List of classes to applied to the parent `div`
-        */
-       article.getClasses = function ( link ) {
-               var
-                       classes = [],
-                       cache = mw.popups.render.cache [ link.attr( 'href' ) ],
-                       tall = cache.settings.tall,
-                       thumbnail = cache.settings.thumbnail,
-                       flippedY = cache.settings.flippedY,
-                       flippedX = cache.settings.flippedX;
-
-               if ( flippedY ) {
-                       classes.push( 'mwe-popups-fade-in-down' );
-               } else {
-                       classes.push( 'mwe-popups-fade-in-up' );
-               }
-
-               if ( flippedY && flippedX ) {
-                       classes.push( 'flipped_x_y' );
-               }
-
-               if ( flippedY && !flippedX ) {
-                       classes.push( 'flipped_y' );
-               }
-
-               if ( flippedX && !flippedY ) {
-                       classes.push( 'flipped_x' );
-               }
-
-               if ( ( !thumbnail || tall ) && !flippedY ) {
-                       classes.push( 'mwe-popups-no-image-tri' );
-               }
-
-               if ( ( thumbnail && !tall ) && !flippedY ) {
-                       classes.push( 'mwe-popups-image-tri' );
-               }
-
-               if ( tall ) {
-                       classes.push( 'mwe-popups-is-tall' );
-               } else {
-                       classes.push( 'mwe-popups-is-not-tall' );
-               }
-
-               return classes;
-       };
-
-       /**
-        * Processed the popup div after it has been displayed
-        * to correctly render the triangle/pokeys
-        *
-        * @method processPopups
-        * @param {jQuery} link
-        * @param {Object} logData data to be logged
-        */
-       article.processPopup = function ( link, logData ) {
-               var
-                       cache = mw.popups.render.cache [ link.attr( 'href' ) ],
-                       popup = mw.popups.$popup,
-                       tall = cache.settings.tall,
-                       thumbnail = cache.settings.thumbnail,
-                       flippedY = cache.settings.flippedY,
-                       flippedX = cache.settings.flippedX;
-
-               popup.find( '.mwe-popups-settings-icon' ).click( function () {
-                       delete logData.pageTitleHover;
-                       delete logData.namespaceIdHover;
-
-                       mw.popups.settings.open( $.extend( {}, logData ) );
-
-                       mw.track( 'ext.popups.event', $.extend( logData, {
-                               action: 'tapped settings cog'
-                       } ) );
-               } );
-
-               if ( !flippedY && !tall && cache.settings.thumbnail.height < 
SIZES.landscapeImage.h ) {
-                       $( '.mwe-popups-extract' ).css(
-                               'margin-top',
-                               cache.settings.thumbnail.height - 
SIZES.pokeySize
-                       );
-               }
-
-               if ( flippedY ) {
-                       popup.css( {
-                               top: popup.offset().top - popup.outerHeight()
-                       } );
-               }
-
-               if ( flippedY && thumbnail ) {
-                       mw.popups.$popup
-                               .find( 'image' )[ 0 ]
-                               .setAttribute( 'clip-path', '' );
-               }
-
-               if ( flippedY && flippedX && thumbnail && tall ) {
-                       mw.popups.$popup
-                               .find( 'image' )[ 0 ]
-                               .setAttribute( 'clip-path', 
'url(#mwe-popups-landscape-mask-flip)' );
-               }
-
-               if ( flippedX && !flippedY && thumbnail && !tall ) {
-                       mw.popups.$popup
-                               .find( 'image' )[ 0 ]
-                               .setAttribute( 'clip-path', 
'url(#mwe-popups-mask-flip)' );
-               }
-
-               if ( flippedX && !flippedY && thumbnail && tall ) {
-                       mw.popups.$popup
-                               .removeClass( 'mwe-popups-no-image-tri' )
-                               .find( 'image' )[ 0 ]
-                               .setAttribute( 'clip-path', 
'url(#mwe-popups-landscape-mask)' );
-               }
-       };
-
-       mw.popups.render.renderers.article = article;
-
-       /**
-        * Given the rectangular box(es) find the 'y' boundary of the closest
-        * rectangle to the point 'y'. The point 'y' is the location of the 
mouse
-        * on the 'y' axis and the rectangular box(es) are the borders of the
-        * element over which the mouse is located. There will be more than one
-        * rectangle in case the element spans multiple lines.
-        * In the majority of cases the mouse pointer will be inside a 
rectangle.
-        * However, some browsers (i.e. Chrome) trigger a hover action even when
-        * the mouse pointer is just outside a bounding rectangle. That's why
-        * we need to look at all rectangles and not just the rectangle that
-        * encloses the point.
-        *
-        * @param {number} y the point for which the closest location is being
-        *  looked for
-        * @param {ClientRectList} rects list of rectangles defined by four 
edges
-        * @param {boolean} [isTop] should the resulting rectangle's top 'y'
-        *  boundary be returned. By default the bottom 'y' value is returned.
-        * @return {number}
-        */
-       function getClosestYPosition( y, rects, isTop ) {
-               var result,
-                       deltaY,
-                       minY = null;
-
-               $.each( rects, function ( i, rect ) {
-                       deltaY = Math.abs( y - rect.top + y - rect.bottom );
-
-                       if ( minY === null || minY > deltaY ) {
-                               minY = deltaY;
-                               // Make sure the resulting point is at or 
outside the rectangle
-                               // boundaries.
-                               result = ( isTop ) ? Math.floor( rect.top ) : 
Math.ceil( rect.bottom );
-                       }
-               } );
-
-               return result;
-       }
-
-       /**
-        * Aborts any pending ajax requests
-        */
-       mw.popups.render.abortCurrentRequest = function () {
-               if ( currentRequest ) {
-                       currentRequest.abort();
-                       currentRequest = undefined;
-               }
-       };
-
-       /**
-        * Expose for tests
-        */
-       mw.popups.render.getClosestYPosition = getClosestYPosition;
-
-       /**
-        * Remove ellipsis if exists at the end
-        */
-       function removeEllipsis( text ) {
-               return text.replace( /\.\.\.$/, '' );
-       }
-
-} )( jQuery, mediaWiki );
diff --git a/resources/ext.popups.desktop/popup.mustache 
b/resources/ext.popups.desktop/popup.mustache
index 064e85e..9bf4e2a 100644
--- a/resources/ext.popups.desktop/popup.mustache
+++ b/resources/ext.popups.desktop/popup.mustache
@@ -1,12 +1,14 @@
-<div>
-       {{#hasThumbnail}}
-       <a href="{{href}}" class="mwe-popups-discreet"><!-- thumbnail injected 
post render --></a>
-       {{/hasThumbnail}}
-       <a dir="{{langdir}}" lang="{{langcode}}" class="mwe-popups-extract" 
href="{{href}}"><!-- extract will be appended here --></a>
-       <footer>
-                       <span
-                               
class="{{#isRecent}}mwe-popups-timestamp-recent{{/isRecent}}{{^isRecent}}mwe-popups-timestamp-older{{/isRecent}}">{{lastModified}}</span>
-                       <a class="mwe-popups-icon mwe-popups-settings-icon"></a>
-                       <!-- survey link injected here if found -->
-       </footer>
-</div>
\ No newline at end of file
+{{! Extracted from `mw.popups.render.createPopupElement` }}
+<div class="mwe-popups" role="tooltip" aria-hidden>
+  <div>
+    {{#hasThumbnail}}
+    <a href="{{href}}" class="mwe-popups-discreet"><!-- thumbnail injected 
post render --></a>
+    {{/hasThumbnail}}
+    <a dir="{{langdir}}" lang="{{langcode}}" class="mwe-popups-extract" 
href="{{href}}"><!-- extract will be appended here --></a>
+    <footer>
+        <span
+          
class="{{#isRecent}}mwe-popups-timestamp-recent{{/isRecent}}{{^isRecent}}mwe-popups-timestamp-older{{/isRecent}}">{{lastModified}}</span>
+        <a class="mwe-popups-icon mwe-popups-settings-icon"></a>
+    </footer>
+  </div>
+</div>
diff --git a/resources/ext.popups.renderer.desktopRenderer/desktopRenderer.js 
b/resources/ext.popups.renderer.desktopRenderer/desktopRenderer.js
deleted file mode 100644
index b94edd9..0000000
--- a/resources/ext.popups.renderer.desktopRenderer/desktopRenderer.js
+++ /dev/null
@@ -1,398 +0,0 @@
-/*global popupDelay: true, popupHideDelay: true*/
-
-( function ( $, mw ) {
-       var closeTimer, openTimer,
-               $activeLink = null,
-               logData = {};
-
-       /**
-        * Sets the link that the currently shown popup relates to
-        *
-        * @ignore
-        * @param {jQuery|null} [$link] if undefined there is no active link
-        */
-       function setActiveLink( $link ) {
-               $activeLink = $link;
-       }
-
-       /**
-        * Gets the link that the currently shown popup relates to
-        *
-        * @ignore
-        * @return {jQuery|null} if undefined there is no active link
-        */
-       function getActiveLink() {
-               return $activeLink;
-       }
-
-       /**
-        * Logs the click on link or popup
-        *
-        * @param {Object} event
-        */
-       function logClickAction( event ) {
-               mw.track( 'ext.popups.event', $.extend( {}, logData, {
-                       action: mw.popups.getAction( event )
-               } ) );
-       }
-
-       /**
-        * Logs when a popup is dismissed
-        */
-       function logDismissAction() {
-               mw.track( 'ext.popups.event', $.extend( {}, logData, {
-                       action: 'dismissed'
-               } ) );
-       }
-       /**
-        * @class mw.popups.render
-        * @singleton
-        */
-       mw.popups.render = {};
-
-       /**
-        * Time to wait in ms before showing a popup on hover.
-        * Use the navigation popup delay if it has been set by the user.
-        * This isn't the official way of setting the delay
-        * TODO: Add setting to change delay
-        * @property POPUP_DELAY
-        */
-       mw.popups.render.POPUP_DELAY = ( typeof popupDelay === 'undefined' ) ?
-               500 :
-               popupDelay * 1000;
-
-       /**
-        * Time to wait in ms before closing a popup on de-hover.
-        * Use the navigation popup delay if it has been set by the user
-        * This isn't the official way of setting the delay
-        * TODO: Add setting to change delay
-        * @property POPUP_CLOSE_DELAY
-        */
-       mw.popups.render.POPUP_CLOSE_DELAY = ( typeof popupHideDelay === 
'undefined' ) ?
-               300 :
-               popupHideDelay * 1000;
-
-       /**
-        * Time to wait in ms before starting the API queries on hover, must be 
<= POPUP_DELAY
-        * @property API_DELAY
-        */
-       mw.popups.render.API_DELAY = 50;
-
-       /**
-        * Cache of all the popups that were opened in this session
-        * @property {Object} cache
-        */
-       mw.popups.render.cache = {};
-
-       /**
-        * Object to store all renderers
-        * @property {Object} renderers
-        */
-       mw.popups.render.renderers = {};
-
-       /**
-        * Close all other popups and render the new one from the cache
-        * or by finding and calling the correct renderer
-        *
-        * @method render
-        * @param {jQuery.Object} $link that a hovercard should be shown for
-        * @param {jQuery.Event} event that triggered the render
-        * @param {string} linkInteractionToken random token representing the 
current interaction with the link
-        */
-       mw.popups.render.render = function ( $link, event, linkInteractionToken 
) {
-               var linkHref = $link.attr( 'href' ),
-                       $activeLink = getActiveLink();
-
-               // This will happen when the mouse goes from the popup box back 
to the
-               // anchor tag. In such a case, the timer to close the box is 
cleared.
-               if (
-                       $activeLink &&
-                       $activeLink[ 0 ] === $link[ 0 ]
-               ) {
-                       if ( closeTimer ) {
-                               closeTimer.abort();
-                       }
-                       return;
-               }
-
-               // If the mouse moves to another link (we already check if its 
the same
-               // link in the previous condition), then close the popup.
-               if ( $activeLink ) {
-                       mw.popups.render.closePopup( logDismissAction );
-               }
-
-               // Ignore if its meant to call a function
-               // TODO: Remove this when adding reference popups
-               if ( linkHref === '#' ) {
-                       return;
-               }
-
-               setActiveLink( $link );
-               // Set the log data only after the current link is set, 
otherwise, functions like
-               // closePopup will use the new log data when closing an old 
popup.
-               logData = {
-                       pageTitleHover: mw.popups.getTitle( linkHref ),
-                       linkInteractionToken: linkInteractionToken
-               };
-
-               $link
-                       .on( 'mouseleave blur', leaveInactive )
-                       .off( 'click', clickHandler )
-                       .on( 'click', clickHandler );
-
-               if ( mw.popups.render.cache[ $link.attr( 'href' ) ] ) {
-                       openTimer = mw.popups.render.wait( 
mw.popups.render.POPUP_DELAY )
-                               .done( function () {
-                                       mw.popups.render.openPopup( $link, 
event );
-                               } );
-               } else {
-                       // Wait for timer before making API queries and showing 
hovercard
-                       openTimer = mw.popups.render.wait( 
mw.popups.render.API_DELAY )
-                               .done( function () {
-                                       var cachePopup, key,
-                                               renderers = 
mw.popups.render.renderers;
-
-                                       // Check run the matcher method of all 
renderers to find the right one
-                                       for ( key in renderers ) {
-                                               if ( renderers.hasOwnProperty( 
key ) && key !== 'article' ) {
-                                                       if ( !!renderers[ key 
].matcher( $link.attr( 'href' ) ) ) {
-                                                               cachePopup = 
renderers[ key ].init( $link, $.extend( {}, logData ) );
-                                                       }
-                                               }
-                                       }
-
-                                       // Use the article renderer if nothing 
else matches
-                                       if ( cachePopup === undefined ) {
-                                               cachePopup = 
mw.popups.render.renderers.article.init( $link, $.extend( {}, logData ) );
-                                       }
-
-                                       openTimer = mw.popups.render.wait( 
mw.popups.render.POPUP_DELAY - mw.popups.render.API_DELAY );
-
-                                       $.when( openTimer, cachePopup ).done( 
function () {
-                                               mw.popups.render.openPopup( 
$link, event );
-                                       } );
-                               } );
-               }
-       };
-
-       /**
-        * Retrieves the popup from the cache, uses its offset function
-        * applied classes and calls the process function.
-        * Takes care of event logging and attaching other events.
-        *
-        * @method openPopup
-        * @param {Object} link
-        * @param {Object} event
-        */
-       mw.popups.render.openPopup = function ( link, event ) {
-               var
-                       cache = mw.popups.render.cache [ link.attr( 'href' ) ],
-                       popup = cache.popup,
-                       offset = cache.getOffset( link, event ),
-                       classes = cache.getClasses( link );
-
-               mw.popups.$popup
-                       .html( '' )
-                       .attr( 'class', 'mwe-popups' )
-                       .addClass( classes.join( ' ' ) )
-                       .css( offset )
-                       // Clone the element to avoid manipulating the cached 
object accidentally (see T68496)
-                       .append( popup.clone() )
-                       .show()
-                       .attr( 'aria-hidden', 'false' )
-                       .on( 'mouseleave', leaveActive )
-                       .on( 'mouseenter', function () {
-                               if ( closeTimer ) {
-                                       closeTimer.abort();
-                               }
-                       } );
-
-               // Hack to 'refresh' the SVG and thus display it
-               // Elements get added to the DOM and not to the screen because 
of different namespaces of HTML and SVG
-               // More information and workarounds here - 
http://stackoverflow.com/a/13654655/366138
-               mw.popups.$popup.html( mw.popups.$popup.html() );
-
-               // Event logging
-               $.extend( logData, {
-                       pageTitleHover: cache.settings.title,
-                       namespaceIdHover: cache.settings.namespace
-               } );
-               mw.track( 'ext.popups.event', $.extend( {}, logData, {
-                               action: 'display'
-                       } )
-               );
-
-               cache.process( link, $.extend( {}, logData ) );
-
-               mw.popups.$popup.find( 'a.mwe-popups-extract, 
a.mwe-popups-discreet' ).click( clickHandler );
-
-               link
-                       .off( 'mouseleave blur', leaveInactive )
-                       .on( 'mouseleave blur', leaveActive );
-
-               $( document ).on( 'keydown', closeOnEsc );
-
-               mw.popups.incrementPreviewCount();
-       };
-
-       /**
-        * Click handler for the hovercard
-        *
-        * @method clickHandler
-        * @ignore
-        * @param {Object} event
-        */
-       function clickHandler( event ) {
-               var action = mw.popups.getAction( event ),
-                       $activeLink = getActiveLink();
-
-               logClickAction( event );
-
-               if ( action === 'opened in same tab' ) {
-                       window.location.href = $activeLink.attr( 'href' );
-               }
-
-               // close the popup
-               mw.popups.render.closePopup();
-       }
-
-       /**
-        * Removes the hover class from the link and unbinds events
-        * Hides the popup, clears timers and sets it and the resets the 
renderer
-        *
-        * @param {Function} [logCallback] callback used to log before logData 
is reset
-        * @method closePopup
-        */
-       mw.popups.render.closePopup = function ( logCallback ) {
-               var fadeInClass, fadeOutClass,
-                       $activeLink = getActiveLink();
-
-               $activeLink.off( 'mouseleave blur', leaveActive );
-
-               fadeInClass = ( mw.popups.$popup.hasClass( 
'mwe-popups-fade-in-up' ) ) ?
-                       'mwe-popups-fade-in-up' :
-                       'mwe-popups-fade-in-down';
-
-               fadeOutClass = ( fadeInClass === 'mwe-popups-fade-in-up' ) ?
-                       'mwe-popups-fade-out-down' :
-                       'mwe-popups-fade-out-up';
-
-               mw.popups.$popup
-                       .off( 'mouseleave', leaveActive )
-                       .removeClass( fadeInClass )
-                       .addClass( fadeOutClass );
-
-               mw.popups.render.wait( 150 ).done( function () {
-                       if ( mw.popups.$popup.hasClass( fadeOutClass ) ) {
-                               mw.popups.$popup
-                                       .attr( 'aria-hidden', 'true' )
-                                       .hide()
-                                       .removeClass( 
'mwe-popups-fade-out-down' );
-                       }
-               } );
-
-               if ( closeTimer ) {
-                       closeTimer.abort();
-               }
-
-               $( document ).off( 'keydown', closeOnEsc );
-
-               if ( $.isFunction( logCallback ) ) {
-                       logCallback();
-               }
-
-               mw.popups.render.reset();
-       };
-
-       /**
-        * Return a promise corresponding to a `setTimeout()` call. Call 
`.abort()` on the return value
-        * to perform the equivalent of `clearTimeout()`
-        *
-        * @method wait
-        * @param {number} ms Milliseconds to wait
-        * @return {jQuery.Promise}
-        */
-       mw.popups.render.wait = function ( ms ) {
-               var deferred, promise, timeout;
-
-               deferred = $.Deferred();
-
-               timeout = setTimeout( function () {
-                       deferred.resolve();
-               }, ms );
-
-               promise = deferred.promise( { abort: function () {
-                       clearTimeout( timeout );
-                       deferred.reject();
-               } } );
-
-               return promise;
-       };
-
-       /**
-        * Use escape to close popup
-        *
-        * @method closeOnEsc
-        * @ignore
-        * @param {jQuery.Event} event
-        */
-       function closeOnEsc( event ) {
-               var $activeLink = getActiveLink();
-               if ( event.keyCode === 27 && $activeLink ) {
-                       mw.popups.render.closePopup( logDismissAction );
-               }
-       }
-
-       /**
-        * Closes the box after a delay
-        * Delay to give enough time for the user to move the pointer from
-        * the link to the popup box. Also avoids closing the popup by accident
-        *
-        * @method leaveActive
-        * @ignore
-        */
-       function leaveActive() {
-               closeTimer = mw.popups.render.wait( 
mw.popups.render.POPUP_CLOSE_DELAY ).done( function () {
-                       var $activeLink = getActiveLink();
-                       if ( $activeLink ) {
-                               mw.popups.render.closePopup( logDismissAction );
-                       }
-               } );
-       }
-
-       /**
-        * Unbinds events on the anchor tag and aborts AJAX request.
-        *
-        * @method leaveInactive
-        * @ignore
-        */
-       function leaveInactive() {
-               var $activeLink = getActiveLink();
-
-               mw.track( 'ext.popups.event', $.extend( {}, logData, {
-                       action: 'dwelledButAbandoned'
-               } ) );
-
-               $activeLink.off( 'mouseleave blur', leaveInactive );
-               if ( openTimer ) {
-                       openTimer.abort();
-               }
-               mw.popups.render.abortCurrentRequest();
-
-               mw.popups.render.reset();
-       }
-
-       /**
-        * Resets the renderer
-        *
-        * @method reset
-        */
-       mw.popups.render.reset = function () {
-               logData = {};
-               setActiveLink( null );
-               mw.popups.render.abortCurrentRequest();
-               openTimer = undefined;
-               closeTimer = undefined;
-       };
-
-} )( jQuery, mediaWiki );
diff --git a/resources/ext.popups.targets.desktopTarget/desktopTarget.js 
b/resources/ext.popups.targets.desktopTarget/desktopTarget.js
deleted file mode 100644
index 0d51b26..0000000
--- a/resources/ext.popups.targets.desktopTarget/desktopTarget.js
+++ /dev/null
@@ -1,200 +0,0 @@
-( function ( $, mw ) {
-       /**
-        * Check whether the Navigation Popups gadget is enabled
-        *
-        * @return {boolean}
-        */
-       function isNavigationPopupsGadgetEnabled() {
-               return window.pg !== undefined;
-       }
-
-       /**
-        * `mouseleave` and `blur` events handler for links that are eligible 
for
-        * popups, but when Popups are disabled.
-        *
-        * @param {Object} event
-        */
-       function onLinkAbandon( event ) {
-               var $this = $( this ),
-                       data = event.data || {};
-
-               $this.off( 'mouseleave.popups blur.popups', onLinkAbandon );
-
-               mw.track( 'ext.popups.event', {
-                       pageTitleHover: $this.attr( 'title' ),
-                       action: 'dwelledButAbandoned',
-                       linkInteractionToken: data.linkInteractionToken,
-                       hovercardsSuppressedByGadget: 
data.hovercardsSuppressedByGadget
-               } );
-       }
-
-       /**
-        * `mouseenter` and `focus` events handler for links that are eligible 
for
-        * popups. Handles the disply of a popup.
-        *
-        * @param {Object} event
-        */
-       function onLinkHover( event ) {
-               var $link = $( this ),
-                       // Cache the hover start time and link interaction 
token for a later use
-                       eventData = {
-                               linkInteractionToken: 
mw.popups.getRandomToken(),
-                               hovercardsSuppressedByGadget: 
isNavigationPopupsGadgetEnabled()
-                       };
-
-               mw.track( 'ext.popups.event', $.extend( {}, eventData, {
-                               action: 'hover'
-                       } )
-               );
-               // Only enable Popups when the Navigation popups gadget is not 
enabled
-               if ( !eventData.hovercardsSuppressedByGadget && 
mw.popups.enabled ) {
-                       if ( mw.popups.scrolled ) {
-                               return;
-                       }
-
-                       mw.popups.removeTooltip( $link );
-                       mw.popups.render.render( $link, event, 
eventData.linkInteractionToken );
-               } else {
-                       // Remove existing handlers and replace with logging 
only
-                       $link
-                               .off( 'mouseleave.popups blur.popups 
mouseenter.popups focus.popups click.popups' )
-                               // We are passing the same data, rather than a 
shared object, into two different
-                               // functions so that one function doesn't 
change the data and
-                               // have a side-effect on the other function's 
data.
-                               .on( 'mouseleave.popups blur.popups', 
eventData, onLinkAbandon )
-                               .on( 'click.popups', eventData, onLinkClick );
-               }
-       }
-
-       /**
-        * `mouseleave` and `blur` events handler for links that are eligible 
for
-        * popups. Handles the restoration of title attributes
-        */
-       function onLinkBlur() {
-               mw.popups.restoreTooltip( $( this ) );
-       }
-
-       /**
-        * `click` event handler for links that are eligible for popups, but 
when
-        * Popups are disabled.
-        *
-        * @param {Object} event
-        */
-       function onLinkClick( event ) {
-               var $this = $( this ),
-                       action = mw.popups.getAction( event ),
-                       href = $this.attr( 'href' ),
-                       data = event.data || {};
-
-               $this.off( 'click.popups', onLinkClick );
-
-               mw.track( 'ext.popups.event', {
-                       pageTitleHover: $this.attr( 'title' ),
-                       action: action,
-                       linkInteractionToken: data.linkInteractionToken,
-                       hovercardsSuppressedByGadget: 
data.hovercardsSuppressedByGadget
-               } );
-
-               if ( action  === 'opened in same tab' ) {
-                       event.preventDefault();
-                       window.location.href = href;
-               }
-       }
-
-       mw.popups.enabled = mw.popups.getEnabledState();
-
-       /**
-        * Creates the SVG mask used to create the
-        * the triangle pointer on popups with images
-        *
-        * @method createSVGMask
-        */
-       mw.popups.createSVGMask = function () {
-               $( '<div>' )
-                       .attr( 'id', 'mwe-popups-svg' )
-                       .appendTo( document.body )
-                       .html(
-                               '<svg width="0" height="0">' +
-                                       '<defs>' +
-                                               '<clippath 
id="mwe-popups-mask">' +
-                                                       '<polygon points="0 8, 
10 8, 18 0, 26 8, 1000 8, 1000 1000, 0 1000"/>' +
-                                               '</clippath>' +
-                                               '<clippath 
id="mwe-popups-mask-flip">' +
-                                                       '<polygon points="0 8, 
274 8, 282 0, 290 8, 1000 8, 1000 1000, 0 1000"/>' +
-                                               '</clippath>' +
-                                               '<clippath 
id="mwe-popups-landscape-mask">' +
-                                                       '<polygon points="0 8, 
174 8, 182 0, 190 8, 1000 8, 1000 1000, 0 1000"/>' +
-                                               '</clippath>' +
-                                               '<clippath 
id="mwe-popups-landscape-mask-flip">' +
-                                                       '<polygon points="0 0, 
1000 0, 1000 243, 190 243, 182 250, 174 243, 0 243"/>' +
-                                               '</clippath>' +
-                                       '</defs>' +
-                               '</svg>'
-                       );
-               return true;
-       };
-
-       /**
-        * Create the element that holds the popups
-        *
-        * @method createPopupElement
-        */
-       mw.popups.createPopupElement = function () {
-               mw.popups.$popup = $( '<div>' )
-                       .attr( {
-                               'class': 'mwe-popups',
-                               role: 'tooltip',
-                               'aria-hidden': 'true'
-                       } )
-                       .appendTo( document.body );
-       };
-
-       /**
-        * Checks if the user is scrolling, sets to false on mousemove
-        *
-        * @method checkScroll
-        */
-       mw.popups.checkScroll = function () {
-               $( window ).on( 'scroll.popups', function () {
-                       mw.popups.scrolled = true;
-               } );
-
-               $( window ).on( 'mousemove.popups', function () {
-                       mw.popups.scrolled = false;
-               } );
-       };
-
-       /**
-        * Adds the events necessary to all links within a container
-        * so that a popup shows on hover.
-        *
-        * @param {jQuery.Object} $content to setup mouse events for
-        * @ignore
-        * @method setupMouseEvents
-        */
-       function setupMouseEvents( $content ) {
-               mw.popups.$content = $content;
-               mw.popups.selectPopupElements()
-                       .on( 'mouseenter.popups focus.popups', onLinkHover )
-                       .on( 'mouseleave.popups blur.popups', onLinkBlur );
-       }
-
-       mw.hook( 'wikipage.content' ).add( setupMouseEvents );
-
-       function initPopups() {
-               mw.popups.checkScroll();
-               mw.popups.createSVGMask();
-               mw.popups.createPopupElement();
-       }
-
-       $( function () {
-               if ( mw.popups.enabled ) {
-                       initPopups();
-               }
-
-               mw.track( 'ext.popups.event', {
-                       action: 'pageLoaded',
-                       hovercardsSuppressedByGadget: 
isNavigationPopupsGadgetEnabled()
-               } );
-       } );
-} )( jQuery, mediaWiki );
diff --git a/resources/ext.popups/actions.js b/resources/ext.popups/actions.js
index cacbad4..44a4bf8 100644
--- a/resources/ext.popups/actions.js
+++ b/resources/ext.popups/actions.js
@@ -91,6 +91,7 @@
                                interactionStarted: mw.now()
                        } );
 
+                       // TODO: Use mw.popups.wait.
                        setTimeout( function () {
                                if ( getState().preview.activeLink === el ) {
                                        dispatch( fetch( gateway, $( el ).data( 
'page-previews-title' ) ) );
diff --git a/resources/ext.popups/boot.js b/resources/ext.popups/boot.js
index cdc2844..9ae31b3 100644
--- a/resources/ext.popups/boot.js
+++ b/resources/ext.popups/boot.js
@@ -110,6 +110,8 @@
                                        BLACKLISTED_LINKS
                                );
 
+                       mw.popups.renderer.init();
+
                        previewLinks
                                .on( 'mouseover focus', function ( event ) {
                                        actions.linkDwell( this, event, gateway 
);
diff --git a/resources/ext.popups/reducers.js b/resources/ext.popups/reducers.js
index 7453dc6..e4c11ad 100644
--- a/resources/ext.popups/reducers.js
+++ b/resources/ext.popups/reducers.js
@@ -71,7 +71,8 @@
                                        activeEvent: undefined,
                                        interactionStarted: undefined,
                                        isDelayingFetch: false,
-                                       linkInteractionToken: undefined
+                                       linkInteractionToken: undefined,
+                                       fetchResponse: undefined
                                } );
                        case mw.popups.actionTypes.LINK_CLICK:
                                return nextState( state, {
@@ -86,7 +87,7 @@
                        case mw.popups.actionTypes.FETCH_END:
                                return nextState( state, {
                                        isFetching: false,
-                                       fetchResponse: action.response
+                                       fetchResponse: action.result
                                } );
                        case mw.popups.actionTypes.FETCH_FAILED:
                                return nextState( state, {
diff --git a/resources/ext.popups/renderChangeListener.js 
b/resources/ext.popups/renderChangeListener.js
new file mode 100644
index 0000000..1288e35
--- /dev/null
+++ b/resources/ext.popups/renderChangeListener.js
@@ -0,0 +1,24 @@
+( function ( mw ) {
+
+       /**
+        * Creates an instance of the render change listener.
+        *
+        * @return {ext.popups.ChangeListener}
+        */
+       mw.popups.changeListeners.render = function () {
+               var preview;
+
+               return function ( prevState, state ) {
+                       if ( state.preview.fetchResponse && !preview ) {
+                               preview = mw.popups.renderer.render( 
state.preview.fetchResponse );
+                               preview.show( state.preview.activeEvent );
+                       } else if ( prevState && 
prevState.preview.fetchResponse ) {
+                               preview.hide()
+                                       .done( function () {
+                                               preview = undefined;
+                                       } );
+                       }
+               };
+       };
+
+}( mediaWiki ) );
diff --git a/resources/ext.popups/renderer.js b/resources/ext.popups/renderer.js
new file mode 100644
index 0000000..25a1b3b
--- /dev/null
+++ b/resources/ext.popups/renderer.js
@@ -0,0 +1,619 @@
+( function ( mw, $ ) {
+
+       var isSafari = navigator.userAgent.match( /Safari/ ) !== null,
+               SIZES = {
+                       portraitImage: {
+                               h: 250, // Exact height
+                               w: 203 // Max width
+                       },
+                       landscapeImage: {
+                               h: 200, // Max height
+                               w: 300 // Exact Width
+                       },
+                       landscapePopupWidth: 450,
+                       portraitPopupWidth: 300,
+                       pokeySize: 8 // Height of the pokey.
+               },
+               $window = $( window );
+
+       mw.popups.renderer = {};
+
+       /**
+        * Extracted from `mw.popups.createSVGMasks`.
+        */
+       function createPokeyMasks() {
+               $( '<div>' )
+                       .attr( 'id', 'mwe-popups-svg' )
+                       .html(
+                               '<svg width="0" height="0">' +
+                                       '<defs>' +
+                                               '<clippath 
id="mwe-popups-mask">' +
+                                                       '<polygon points="0 8, 
10 8, 18 0, 26 8, 1000 8, 1000 1000, 0 1000"/>' +
+                                               '</clippath>' +
+                                               '<clippath 
id="mwe-popups-mask-flip">' +
+                                                       '<polygon points="0 8, 
274 8, 282 0, 290 8, 1000 8, 1000 1000, 0 1000"/>' +
+                                               '</clippath>' +
+                                               '<clippath 
id="mwe-popups-landscape-mask">' +
+                                                       '<polygon points="0 8, 
174 8, 182 0, 190 8, 1000 8, 1000 1000, 0 1000"/>' +
+                                               '</clippath>' +
+                                               '<clippath 
id="mwe-popups-landscape-mask-flip">' +
+                                                       '<polygon points="0 0, 
1000 0, 1000 243, 190 243, 182 250, 174 243, 0 243"/>' +
+                                               '</clippath>' +
+                                       '</defs>' +
+                               '</svg>'
+                       )
+                       .appendTo( document.body );
+       }
+
+       /**
+        * Initializes the renderer.
+        */
+       mw.popups.renderer.init = function () {
+               createPokeyMasks();
+       };
+
+       /**
+        * @typedef {Object} mw.popups.Preview
+        */
+
+       /**
+        * Renders a preview given data from the {@link gateway 
ext.popups.Gateway}.
+        * The preview is rendered and added to the DOM but will remain hidden 
until
+        * the `show` method is called.
+        *
+        * Previews are rendered at:
+        *
+        * # The position of the mouse when the user dwells on the link with 
their
+        *   mouse.
+        * # The centermost point of the link when the user dwells on the link 
with
+        *   their keboard or other assistive device.
+        *
+        * Since the content of the preview doesn't change but its position 
might, we
+        * distinguish between "rendering" - generating HTML from a MediaWiki 
API
+        * response - and "showing/hiding" - positioning the layout and 
changing its
+        * orientation, if necessary.
+        *
+        * @param {Object} data
+        * @return {mw.popups.Preview}
+        */
+       mw.popups.renderer.render = function ( data ) {
+               var templateData,
+                       thumbnail = createThumbnail( data.thumbnail ),
+                       hasThumbnail = thumbnail !== null,
+                       extract = renderExtract( data.extract, data.title ),
+                       $el,
+                       preview = {};
+
+               templateData = $.extend( {}, data, {
+                       lastModifiedMsg: mw.msg( 'popups-last-edited', moment( 
data.lastModified ).fromNow() ),
+                       hasThumbnail: hasThumbnail
+               } );
+
+               $el = mw.template.get( 'ext.popups', 'popup.mustache' )
+                       .render( templateData )
+                       .appendTo( document.body );
+
+               if ( hasThumbnail ) {
+                       $el.find( '.mwe-popups-discreet' ).append( thumbnail.el 
);
+               }
+
+               if ( extract.length ) {
+                       $el.find( '.mwe-popups-extract' ).append( extract );
+               }
+
+               preview = {
+                       el: $el,
+                       hasThumbnail: hasThumbnail,
+                       thumbnail: thumbnail,
+                       isTall: hasThumbnail && thumbnail.isTall
+               };
+
+               return {
+
+                       /**
+                        * Shows the preview given an event representing the 
user's interaction
+                        * with the active link, e.g. an instance of
+                        * 
[MouseEvent](https://developer.mozilla.org/en/docs/Web/API/MouseEvent).
+                        *
+                        * See `show` for more detail.
+                        *
+                        * @param {Event} event
+                        * @return {jQuery.Promise}
+                        */
+                       show: function ( event ) {
+                               return show( preview, event );
+                       },
+
+                       /**
+                        * Hides the preview.
+                        *
+                        * See `hide` for more detail.
+                        *
+                        * @return {jQuery.Promise}
+                        */
+                       hide: function () {
+                               return hide( preview );
+                       }
+               };
+       };
+
+       /**
+        * Converts the extract into a list of elements, which correspond to 
fragments
+        * of the extract. Fragements that match the title verbatim are wrapped 
in a
+        * `<b>` element.
+        *
+        * Using the bolded elements of the extract of the page directly is 
covered by
+        * [T141651](https://phabricator.wikimedia.org/T141651).
+        *
+        * Extracted from `mw.popups.renderer.article.getProcessedElements`.
+        *
+        * @param {String} extract
+        * @param {String} title
+        * @return {Array}
+        */
+       function renderExtract( extract, title ) {
+               var regExp, escapedTitle,
+                       elements = [],
+                       boldIdentifier = '<bi-' + Math.random() + '>',
+                       snip = '<snip-' + Math.random() + '>';
+
+               title = title.replace( /\s+/g, ' ' ).trim(); // Remove extra 
white spaces
+               escapedTitle = mw.RegExp.escape( title ); // Escape RegExp 
elements
+               regExp = new RegExp( '(^|\\s)(' + escapedTitle + ')(|$)', 'i' );
+
+               // Remove text in parentheses along with the parentheses
+               extract = extract.replace( /\s+/, ' ' ); // Remove extra white 
spaces
+
+               // Make title bold in the extract text
+               // As the extract is html escaped there can be no such string 
in it
+               // Also, the title is escaped of RegExp elements thus can't 
have "*"
+               extract = extract.replace( regExp, '$1' + snip + boldIdentifier 
+ '$2' + snip + '$3' );
+               extract = extract.split( snip );
+
+               $.each( extract, function ( index, part ) {
+                       if ( part.indexOf( boldIdentifier ) === 0 ) {
+                               elements.push( $( '<b>' ).text( part.substring( 
boldIdentifier.length ) ) );
+                       } else {
+                               elements.push( document.createTextNode( part ) 
);
+                       }
+               } );
+
+               return elements;
+       }
+
+       /**
+        * Shows the preview.
+        *
+        * Extracted from `mw.popups.render.openPopup`.
+        *
+        * @param {ext.popups.Preview} preview
+        * @param {Event} event
+        * @return {jQuery.Promise} A promise that resolves when the promise 
has faded
+        *  in
+        */
+       function show( preview, event ) {
+               var layout = createLayout( preview, event );
+
+               // Hack to "refresh" the SVG so that it's displayed.
+               //
+               // Elements get added to the DOM and not to the screen because 
of different
+               // namespaces of HTML and SVG.
+               //
+               // See http://stackoverflow.com/a/13654655/366138 for more 
detail.
+               //
+               // TODO: Find out how early on in the render that this could be 
done, e.g.
+               // createThumbnail?
+               preview.el.html( preview.el.html() );
+
+               layoutPreview( preview, layout );
+
+               preview.el.show();
+
+               return mw.popups.wait( 300 );
+       }
+
+       /**
+        * Extracted from `mw.popups.render.closePopup`.
+        *
+        * @preview {ext.popups.Preview} preview
+        * @return {jQuery.Promise} A promise that resolves when the preview 
has faded
+        *  out
+        */
+       function hide( preview ) {
+               var fadeInClass,
+                       fadeOutClass;
+
+               // FIXME: This method clearly needs access to the layout of the 
preview.
+               fadeInClass = ( preview.el.hasClass( 'mwe-popups-fade-in-up' ) 
) ?
+                       'mwe-popups-fade-in-up' :
+                       'mwe-popups-fade-in-down';
+
+               fadeOutClass = ( fadeInClass === 'mwe-popups-fade-in-up' ) ?
+                       'mwe-popups-fade-out-down' :
+                       'mwe-popups-fade-out-up';
+
+               preview.el
+                       .removeClass( fadeInClass )
+                       .addClass( fadeOutClass );
+
+               return mw.popups.wait( 150 ).then( function () {
+                       preview.el.hide()
+                               .attr( 'aria-hidden', 'true' );
+               } );
+       }
+
+       /**
+        * @typedef {Object} ext.popups.Thumbnail
+        * @property {Element} el
+        * @property {Boolean} isTall Whether or not the thumbnail is portrait
+        */
+
+       /**
+        * Creates a thumbnail from the representation of a thumbnail returned 
by the
+        * PageImages MediaWiki API query module.
+        *
+        * If there's no thumbnail, the thumbnail is too small, or the 
thumbnail's URL
+        * contains characters that could be used to perform an
+        * [XSS attack via 
CSS](https://www.owasp.org/index.php/Testing_for_CSS_Injection_(OTG-CLIENT-005)),
+        * then `null` is returned.
+        *
+        * Extracted from `mw.popups.renderer.article.createThumbnail`.
+        *
+        * @param {Object} rawThumbnail
+        * @return {ext.popups.Thumbnail|null}
+        */
+       function createThumbnail( rawThumbnail ) {
+               var tall, thumbWidth, thumbHeight,
+                       x, y, width, height, clipPath,
+                       devicePixelRatio = $.bracketedDevicePixelRatio();
+
+               if ( !rawThumbnail ) {
+                       return null;
+               }
+
+               tall = rawThumbnail.width < rawThumbnail.height;
+               thumbWidth = rawThumbnail.width / devicePixelRatio;
+               thumbHeight = rawThumbnail.height / devicePixelRatio;
+
+               if (
+                       // Image too small for landscape display
+                       ( !tall && thumbWidth < SIZES.landscapeImage.w ) ||
+                       // Image too small for portrait display
+                       ( tall && thumbHeight < SIZES.portraitImage.h ) ||
+                       // These characters in URL that could inject CSS and 
thus JS
+                       (
+                               rawThumbnail.url.indexOf( '\\' ) > -1 ||
+                               rawThumbnail.url.indexOf( '\'' ) > -1 ||
+                               rawThumbnail.url.indexOf( '\"' ) > -1
+                       )
+               ) {
+                       return null;
+               }
+
+               if ( tall ) {
+                       x = ( thumbWidth > SIZES.portraitImage.w ) ?
+                               ( ( thumbWidth - SIZES.portraitImage.w ) / -2 ) 
:
+                               ( SIZES.portraitImage.w - thumbWidth );
+                       y = ( thumbHeight > SIZES.portraitImage.h ) ?
+                               ( ( thumbHeight - SIZES.portraitImage.h ) / -2 
) : 0;
+                       width = SIZES.portraitImage.w;
+                       height = SIZES.portraitImage.h;
+               } else {
+                       x = 0;
+                       y = ( thumbHeight > SIZES.landscapeImage.h ) ?
+                               ( ( thumbHeight - SIZES.landscapeImage.h ) / -2 
) : 0;
+                       width = SIZES.landscapeImage.w + 3;
+                       height = ( thumbHeight > SIZES.landscapeImage.h ) ?
+                               SIZES.landscapeImage.h : thumbHeight;
+                       clipPath = 'mwe-popups-mask';
+               }
+
+               return {
+                       el: createThumbnailElement(
+                               tall ? 'mwe-popups-is-tall' : 
'mwe-popups-is-not-tall',
+                               rawThumbnail.url,
+                               x,
+                               y,
+                               thumbWidth,
+                               thumbHeight,
+                               width,
+                               height,
+                               clipPath
+                       ),
+                       isTall: tall,
+                       width: thumbWidth,
+                       height: thumbHeight
+               };
+       }
+
+       /**
+        * Creates the SVG image element that represents the thumbnail.
+        *
+        * This function is distinct from `createThumbnail` as it abstracts 
away some
+        * browser issues that are uncovered when manipulating elements across
+        * namespaces.
+        *
+        * @param {String} className
+        * @param {String} url
+        * @param {Number} x
+        * @param {Number} y
+        * @param {Number} thumbnailWidth
+        * @param {Number} thumbnailHeight
+        * @param {Number} width
+        * @param {Number} height
+        * @param {String} clipPath
+        * @return {jQuery}
+        */
+       function createThumbnailElement( className, url, x, y, thumbnailWidth, 
thumbnailHeight, width, height, clipPath ) {
+               var $thumbnailSVGImage, $thumbnail,
+                       ns = 'http://www.w3.org/2000/svg',
+
+                       // Use createElementNS to create the svg:image tag as 
jQuery uses
+                       // createElement instead. Some browsers mistakenly map 
the image tag to
+                       // img tag.
+                       svgElement = document.createElementNS( 
'http://www.w3.org/2000/svg', 'image' );
+
+               $thumbnailSVGImage = $( svgElement );
+               $thumbnailSVGImage
+                       .addClass( className )
+                       .attr( {
+                               x: x,
+                               y: y,
+                               width: thumbnailWidth,
+                               height: thumbnailHeight,
+                               'clip-path': 'url(#' + clipPath + ')'
+                       } );
+
+               // Certain browsers, e.g. IE9, will not correctly set 
attributes from
+               // foreign namespaces using Element#setAttribute (see T134979). 
Apart from
+               // Safari, all supported browsers can set them using 
Element#setAttributeNS
+               // (see T134979).
+               if ( isSafari ) {
+                       svgElement.setAttribute( 'xlink:href', url );
+               } else {
+                       svgElement.setAttributeNS( ns, 'xlink:href', url );
+               }
+               $thumbnail = $( '<svg>' )
+                       .attr( {
+                               xmlns: ns,
+                               width: width,
+                               height: height
+                       } )
+                       .append( $thumbnailSVGImage );
+
+               return $thumbnail;
+       }
+
+       /**
+        * Represents the layout of a preview, which consists of a position 
(`offset`)
+        * and whether or not the preview should be flipped horizontally or
+        * vertically (`flippedX` and `flippedY` respectively).
+        *
+        * @typedef {Object} ext.popups.PreviewLayout
+        * @property {Object} offset
+        * @property {Boolean} flippedX
+        * @property {Boolean} flippedY
+        */
+
+       /**
+        * Extracted from `mw.popups.renderer.article.getOffset`.
+        *
+        * @param {ext.popups.Preview} preview
+        * @param {Object} event
+        * @return {ext.popups.PreviewLayout}
+        */
+       function createLayout( preview, event ) {
+               var flippedX = false,
+                       flippedY = false,
+                       link = $( event.target ),
+                       offsetTop = ( event.pageY ) ? // If it was a mouse event
+                               // Position according to mouse
+                               // Since client rectangles are relative to the 
viewport,
+                               // take scroll position into account.
+                               getClosestYPosition(
+                                       event.pageY - $window.scrollTop(),
+                                       link.get( 0 ).getClientRects(),
+                                       false
+                               ) + $window.scrollTop() + SIZES.pokeySize :
+                               // Position according to link position or size
+                               link.offset().top + link.height() + 
SIZES.pokeySize,
+                       clientTop = ( event.clientY ) ?
+                               event.clientY :
+                               offsetTop,
+                       offsetLeft = ( event.pageX ) ?
+                               event.pageX :
+                               link.offset().left;
+
+               // X Flip
+               if ( offsetLeft > ( $window.width() / 2 ) ) {
+                       offsetLeft += ( !event.pageX ) ? link.width() : 0;
+                       offsetLeft -= !preview.isTall ?
+                               SIZES.portraitPopupWidth :
+                               SIZES.landscapePopupWidth;
+                       flippedX = true;
+               }
+
+               if ( event.pageX ) {
+                       offsetLeft += ( flippedX ) ? 20 : -20;
+               }
+
+               // Y Flip
+               if ( clientTop > ( $window.height() / 2 ) ) {
+                       flippedY = true;
+
+                       // Mirror the positioning of the preview when there's 
no "Y flip": rest
+                       // the pokey on the edge of the link's bounding 
rectangle. In this case
+                       // the edge is the top-most.
+                       offsetTop = link.offset().top - SIZES.pokeySize;
+
+                       // Change the Y position to the top of the link
+                       if ( event.pageY ) {
+                               // Since client rectangles are relative to the 
viewport,
+                               // take scroll position into account.
+                               offsetTop = getClosestYPosition(
+                                       event.pageY - $window.scrollTop(),
+                                       link.get( 0 ).getClientRects(),
+                                       true
+                               ) + $window.scrollTop();
+                       }
+               }
+
+               return {
+                       offset: {
+                               top: offsetTop,
+                               left: offsetLeft
+                       },
+                       flippedX: flippedX,
+                       flippedY: flippedY
+               };
+       }
+
+       /**
+        * Generates a list of declarative CSS classes that represent the 
layout of
+        * the preview.
+        *
+        * @param {ext.popups.Preview} preview
+        * @param {ext.popups.PreviewLayout} layout
+        * @return {String[]}
+        */
+       function getClasses( preview, layout ) {
+               var classes = [ 'mwe-popups' ];
+
+               if ( layout.flippedY ) {
+                       classes.push( 'mwe-popups-fade-in-down' );
+               } else {
+                       classes.push( 'mwe-popups-fade-in-up' );
+               }
+
+               if ( layout.flippedY && layout.flippedX ) {
+                       classes.push( 'flipped_x_y' );
+               }
+
+               if ( layout.flippedY && !layout.flippedX ) {
+                       classes.push( 'flipped_y' );
+               }
+
+               if ( layout.flippedX && !layout.flippedY ) {
+                       classes.push( 'flipped_x' );
+               }
+
+               if ( ( !preview.hasThumbnail || preview.isTall ) && 
!layout.flippedY ) {
+                       classes.push( 'mwe-popups-no-image-tri' );
+               }
+
+               if ( ( preview.hasThumbnail && !preview.isTall ) && 
!layout.flippedY ) {
+                       classes.push( 'mwe-popups-image-tri' );
+               }
+
+               if ( preview.isTall ) {
+                       classes.push( 'mwe-popups-is-tall' );
+               } else {
+                       classes.push( 'mwe-popups-is-not-tall' );
+               }
+
+               return classes;
+       }
+
+       /**
+        * Lays out the preview given the layout.
+        *
+        * If the preview should be oriented differently, then the pokey is 
updated,
+        * e.g. if the preview should be flipped vertically, then the pokey is
+        * removed.
+        *
+        * If the thumbnail is landscape and isn't the full height of the 
thumbnail
+        * container, then pull the extract up to keep whitespace consistent 
across
+        * previews.
+        *
+        * @param {ext.popups.Preview} preview
+        * @param {ext.popups.PreviewLayout} layout
+        */
+       function layoutPreview( preview, layout ) {
+               var popup = preview.el,
+                       isTall = preview.isTall,
+                       hasThumbnail = preview.hasThumbnail,
+                       thumbnail = preview.thumbnail,
+                       flippedY = layout.flippedY,
+                       flippedX = layout.flippedX,
+                       offsetTop = layout.offset.top;
+
+               if ( !flippedY && !isTall && hasThumbnail && thumbnail.height < 
SIZES.landscapeImage.h ) {
+                       $( '.mwe-popups-extract' ).css(
+                               'margin-top',
+                               thumbnail.height - SIZES.pokeySize
+                       );
+               }
+
+               popup.attr( 'class', getClasses( preview, layout ).join( ' ' ) 
);
+
+               if ( flippedY ) {
+                       offsetTop -= popup.outerHeight();
+               }
+
+               popup.css( {
+                       top: offsetTop,
+                       left: layout.offset.left + 'px'
+               } );
+
+               if ( flippedY && hasThumbnail ) {
+                       popup.find( 'image' )[ 0 ]
+                               .setAttribute( 'clip-path', '' );
+               }
+
+               if ( flippedY && flippedX && hasThumbnail && isTall ) {
+                       popup.find( 'image' )[ 0 ]
+                               .setAttribute( 'clip-path', 
'url(#mwe-popups-landscape-mask-flip)' );
+               }
+
+               if ( flippedX && !flippedY && hasThumbnail && !isTall ) {
+                       popup.find( 'image' )[ 0 ]
+                               .setAttribute( 'clip-path', 
'url(#mwe-popups-mask-flip)' );
+               }
+
+               if ( flippedX && !flippedY && hasThumbnail && isTall ) {
+                       popup.removeClass( 'mwe-popups-no-image-tri' )
+                               .find( 'image' )[ 0 ]
+                               .setAttribute( 'clip-path', 
'url(#mwe-popups-landscape-mask)' );
+               }
+       }
+
+       /**
+        * Given the rectangular box(es) find the 'y' boundary of the closest
+        * rectangle to the point 'y'. The point 'y' is the location of the 
mouse
+        * on the 'y' axis and the rectangular box(es) are the borders of the
+        * element over which the mouse is located. There will be more than one
+        * rectangle in case the element spans multiple lines.
+        *
+        * In the majority of cases the mouse pointer will be inside a 
rectangle.
+        * However, some browsers (i.e. Chrome) trigger a hover action even when
+        * the mouse pointer is just outside a bounding rectangle. That's why
+        * we need to look at all rectangles and not just the rectangle that
+        * encloses the point.
+        *
+        * @param {Number} y the point for which the closest location is being
+        *  looked for
+        * @param {ClientRectList} rects list of rectangles defined by four 
edges
+        * @param {Boolean} [isTop] should the resulting rectangle's top 'y'
+        *  boundary be returned. By default the bottom 'y' value is returned.
+        * @return {Number}
+        */
+       function getClosestYPosition( y, rects, isTop ) {
+               var result,
+                       deltaY,
+                       minY = null;
+
+               $.each( rects, function ( i, rect ) {
+                       deltaY = Math.abs( y - rect.top + y - rect.bottom );
+
+                       if ( minY === null || minY > deltaY ) {
+                               minY = deltaY;
+                               // Make sure the resulting point is at or 
outside the rectangle
+                               // boundaries.
+                               result = ( isTop ) ? Math.floor( rect.top ) : 
Math.ceil( rect.bottom );
+                       }
+               } );
+
+               return result;
+       }
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/ext.popups/wait.js b/resources/ext.popups/wait.js
new file mode 100644
index 0000000..cbe1a1f
--- /dev/null
+++ b/resources/ext.popups/wait.js
@@ -0,0 +1,26 @@
+( function ( mw, $ ) {
+
+       /**
+        * Sugar around `window.setTimeout`.
+        *
+        * @example
+        * function continueProcessing() {
+        *   // ...
+        * }
+        *
+        * mw.popups.wait( 150 ).then( continueProcessing );
+        *
+        * @param {Number} delay The number of milliseconds to wait
+        * @return {jQuery.Deferred}
+        */
+       mw.popups.wait = function ( delay ) {
+               var result = $.Deferred();
+
+               setTimeout( function () {
+                       result.resolve();
+               }, delay );
+
+               return result.promise();
+       };
+
+}( mediaWiki, jQuery ) );
diff --git a/tests/qunit/ext.popups/wait.test.js 
b/tests/qunit/ext.popups/wait.test.js
new file mode 100644
index 0000000..c5d115e
--- /dev/null
+++ b/tests/qunit/ext.popups/wait.test.js
@@ -0,0 +1,23 @@
+( function ( mw ) {
+
+       QUnit.module( 'ext.popups/wait' );
+
+       QUnit.test( 'it should resolve after waiting', function ( assert ) {
+               var done = assert.async();
+
+               this.sandbox.stub( window, 'setTimeout', function ( callback ) {
+                       callback();
+               } );
+
+               mw.popups.wait( 150 ).done( function () {
+                       assert.strictEqual(
+                               window.setTimeout.getCall( 0 ).args[ 1 ],
+                               150,
+                               'It waits for the given duration'
+                       );
+
+                       done();
+               } );
+       } );
+
+}( mediaWiki ) );

-- 
To view, visit https://gerrit.wikimedia.org/r/322635
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I14b437e7c2f55b988837fcb2800dd61a23c29a01
Gerrit-PatchSet: 3
Gerrit-Project: mediawiki/extensions/Popups
Gerrit-Branch: mpga
Gerrit-Owner: Phuedx <[email protected]>
Gerrit-Reviewer: Phuedx <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to