import * as Redux from 'redux'; import * as ReduxThunk from 'redux-thunk'; import createPagePreviewGateway from './gateway/page'; import createUserSettings from './userSettings'; import createPreviewBehavior from './previewBehavior'; import createSettingsDialogRenderer from './ui/settingsDialogRenderer'; import registerChangeListener from './changeListener'; import createIsPagePreviewsEnabled from './isPagePreviewsEnabled'; import { fromElement as titleFromElement } from './title'; import { init as rendererInit, registerPreviewUI, createPagePreview, createDisambiguationPreview } from './ui/renderer'; import createExperiments from './experiments'; import { isEnabled as isStatsvEnabled } from './instrumentation/statsv'; import changeListeners from './changeListeners'; import * as actions from './actions'; import reducers from './reducers'; import createMediaWikiPopupsObject from './integrations/mwpopups'; import { previewTypes, getPreviewType, registerModel, findNearestEligibleTarget } from './preview/model'; import setUserConfigFlags from './setUserConfigFlags'; import { registerGatewayForPreviewType, getGatewayForPreviewType } from './gateway'; import { FETCH_START_DELAY, FETCH_COMPLETE_TARGET_DELAY } from './constants'; /** * @module popups * @private */ const EXCLUDED_LINK_SELECTORS = [ '.extiw', // ignore links that point to the same article '.mw-selflink', '.image', '.new', '.internal', '.external', '.mw-cite-backlink a', '.oo-ui-buttonElement-button', '.ve-ce-surface a', // T259889 '.ext-discussiontools-init-timestamplink', '.cancelLink a', // T198652: lists to hash fragments are ignored. // Note links that include the path will still trigger a hover, // e.g. will trigger a preview but will not. // This is intentional behaviour that will not be handled by page previews, to avoid // introducing complex behaviour. If a link must include the path it should make use of // the .mw-selflink-fragment class. '.mw-selflink-fragment', '[href^="#"]' ]; /** * @typedef {Function} EventTracker * * An analytics event tracker, i.e. `mw.track`. * * @param {string} topic * @param {Object} data */ /** * Gets the appropriate analytics event tracker for logging metrics to StatsD * via [the "StatsD timers and counters" analytics event protocol][0]. * * If logging metrics to StatsD is enabled for the duration of the user's * session, then the appriopriate function is `mw.track`; otherwise it's * `() => {}`. * * [0]: https://github.com/wikimedia/mediawiki-extensions-WikimediaEvents/blob/29c864a0/modules/ext.wikimediaEvents.statsd.js * * @param {Object} user * @param {Object} config * @param {Experiments} experiments * @return {EventTracker} */ function getStatsvTracker( user, config, experiments ) { return isStatsvEnabled( user, config, experiments ) ? mw.track : () => {}; } /** * Gets the appropriate analytics event tracker for logging virtual pageviews. * * @param {Object} config * @return {EventTracker} */ function getPageviewTracker( config ) { return config.get( 'wgPopupsVirtualPageViews' ) ? mw.track : () => { // NOP }; } /** * Subscribes the registered change listeners to the * [store](http://redux.js.org/docs/api/Store.html#store). * * @param {Redux.Store} store * @param {Object} registerActions * @param {UserSettings} userSettings * @param {Function} settingsDialog * @param {PreviewBehavior} previewBehavior * @param {EventTracker} statsvTracker * @param {EventTracker} pageviewTracker */ function registerChangeListeners( store, registerActions, userSettings, settingsDialog, previewBehavior, statsvTracker, pageviewTracker ) { registerChangeListener( store, changeListeners.footerLink( registerActions ) ); registerChangeListener( store, changeListeners.linkTitle() ); registerChangeListener( store, changeListeners.render( previewBehavior ) ); registerChangeListener( store, changeListeners.statsv( registerActions, statsvTracker ) ); registerChangeListener( store, changeListeners.syncUserSettings( userSettings ) ); registerChangeListener( store, changeListeners.settings( registerActions, settingsDialog ) ); registerChangeListener( store, changeListeners.pageviews( registerActions, pageviewTracker ) ); } /** * Creates an event handler that only executes if the current target * is eligible for page previews and a title can be associated with the element. * * @param {Function} handler * @return {Function} */ function handleDOMEventIfEligible( handler ) { return function ( event ) { let target = event && event.target; if ( !target ) { return; } // if the element is a text node, as events can be triggered on text nodes // it won't have a closest method, so we get its parent element (T340081) if ( target.nodeType === 3 ) { target = target.parentNode; } // If the event bubbles up all the way, // document does not have closest method, so exit early (T336650). if ( target === document ) { return; } // If the closest method is not defined, let's return early and // understand this better by logging an error. (T340081) if ( target && !target.closest ) { const err = new Error( `T340081: Unexpected DOM element ${ target.tagName } with nodeType ${ target.nodeType }` ); mw.errorLogger.logError( err, 'error.web-team' ); return; } target = findNearestEligibleTarget( target ); if ( target === null ) { return; } const mwTitle = titleFromElement( target, mw.config ); if ( mwTitle ) { handler( target, mwTitle, event ); } }; } /* * Initialize the application by: * 1. Initializing side-effects and "services" * 2. Creating the state store * 3. Binding the actions to such store * 4. Registering change listeners * 5. Triggering the boot action to bootstrap the system * 6. When the page content is ready: * - Initializing the renderer * - Binding hover and click events to the eligible links to trigger actions */ ( function init() { setUserConfigFlags( mw.config ); let compose = Redux.compose; const // So-called "services". generateToken = mw.user.generateRandomSessionId, pagePreviewGateway = createPagePreviewGateway( mw.config ), userSettings = createUserSettings( mw.storage ), settingsDialog = createSettingsDialogRenderer(), experiments = createExperiments( mw.experiments ), statsvTracker = getStatsvTracker( mw.user, mw.config, experiments ), pageviewTracker = getPageviewTracker( mw.config ), pagePreviewState = createIsPagePreviewsEnabled( mw.user, userSettings, mw.config ); // If debug mode is enabled, then enable Redux DevTools. if ( mw.config.get( 'debug' ) || /* global process */ process.env.NODE_ENV !== 'production' ) { // eslint-disable-next-line no-underscore-dangle compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; } const store = Redux.createStore( Redux.combineReducers( reducers ), compose( Redux.applyMiddleware( ReduxThunk.default ) ) ); const boundActions = Redux.bindActionCreators( actions, store.dispatch ); const previewBehavior = createPreviewBehavior( mw.user, boundActions ); registerChangeListeners( store, boundActions, userSettings, settingsDialog, previewBehavior, statsvTracker, pageviewTracker ); boundActions.boot( {}, mw.user, userSettings, mw.config, window.location.href ); /* * Register external interface exposing popups internals so that other * extensions can query it (T171287) */ mw.popups = createMediaWikiPopupsObject( store, registerModel, registerPreviewUI, registerGatewayForPreviewType, boundActions.registerSetting, userSettings ); // Migrate any old preferences to new system. // FIXME: This can be removed in 4 weeks time. userSettings.migrateOldPreferences(); if ( pagePreviewState !== null ) { const excludedLinksSelector = EXCLUDED_LINK_SELECTORS.join( ', ' ); // Register default preview type mw.popups.register( { type: previewTypes.TYPE_PAGE, selector: `#mw-content-text a[href][title]:not(${ excludedLinksSelector })`, delay: FETCH_COMPLETE_TARGET_DELAY - FETCH_START_DELAY, gateway: pagePreviewGateway, renderFn: createPagePreview, subTypes: [ { type: previewTypes.TYPE_DISAMBIGUATION, renderFn: createDisambiguationPreview, doNotRequireSummary: true } ] } ); } rendererInit(); /* * Binding hover and click events to the eligible links to trigger actions */ function setupEventListeners() { const onHover = handleDOMEventIfEligible( function ( target, mwTitle, event ) { const type = getPreviewType( target ); const gateway = getGatewayForPreviewType( type ); if ( !gateway ) { return; } const scrollTop = window.scrollY; const bbox = target.getBoundingClientRect(); const offset = { top: scrollTop + bbox.y, left: window.scrollX + bbox.x }; const measures = { pageX: event.pageX, pageY: event.pageY, clientY: event.clientY, width: target.offsetWidth, height: target.offsetHeight, offset, clientRects: target.getClientRects(), windowWidth: window.innerWidth, windowHeight: window.innerHeight, scrollTop }; boundActions.linkDwell( mwTitle, target, measures, gateway, generateToken, type ); } ); const onHoverOut = handleDOMEventIfEligible( function () { boundActions.abandon(); } ); const onClick = handleDOMEventIfEligible( function ( target ) { if ( previewTypes.TYPE_PAGE === getPreviewType( target ) ) { boundActions.linkClick( target ); } } ); document.addEventListener( 'mouseover', onHover ); document.addEventListener( 'keyup', onHover ); document.addEventListener( 'mouseout', onHoverOut ); document.addEventListener( 'blur', onHoverOut ); document.addEventListener( 'click', onClick ); } setupEventListeners(); }() ); window.Redux = Redux; window.ReduxThunk = ReduxThunk;