mediawiki-extensions-Popups/src/actions.js

367 lines
8.9 KiB
JavaScript
Raw Normal View History

/**
* @module actions
*/
import types from './actionTypes';
import wait from './wait';
import { createNullModel } from './preview/model';
var $ = jQuery,
mw = window.mediaWiki,
// See the following for context around this value.
//
// * https://phabricator.wikimedia.org/T161284
// * https://phabricator.wikimedia.org/T70861#3129780
FETCH_START_DELAY = 150, // ms.
// The delay after which a FETCH_COMPLETE action should be dispatched.
//
// If the API endpoint responds faster than 500 ms (or, say, the API
// response is served from the UA's cache), then we introduce a delay of
// 500 - t to make the preview delay consistent to the user.
FETCH_COMPLETE_TARGET_DELAY = 500, // ms.
ABANDON_END_DELAY = 300; // ms.
/**
* Mixes in timing information to an action.
*
* Warning: the `baseAction` parameter is modified and returned.
*
* @param {Object} baseAction
* @return {Object}
*/
function timedAction( baseAction ) {
baseAction.timestamp = mw.now();
return baseAction;
}
/**
* Represents Page Previews booting.
*
* When a Redux store is created, the `@@INIT` action is immediately
* dispatched to it. To avoid overriding the term, we refer to booting rather
* than initializing.
*
* Page Previews persists critical pieces of information to local storage.
* Since reading from and writing to local storage are synchronous, Page
* Previews is booted when the browser is idle (using
* [`mw.requestIdleCallback`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback))
* so as not to impact latency-critical events.
*
* @param {Boolean} isEnabled See `isEnabled.js`
* @param {mw.user} user
* @param {ext.popups.UserSettings} userSettings
* @param {Function} generateToken
* @param {mw.Map} config The config of the MediaWiki client-side application,
* i.e. `mw.config`
* @return {Object}
*/
export function boot(
isEnabled,
user,
userSettings,
generateToken,
config
) {
var editCount = config.get( 'wgUserEditCount' ),
previewCount = userSettings.getPreviewCount();
return {
type: types.BOOT,
isEnabled: isEnabled,
isNavPopupsEnabled: config.get( 'wgPopupsConflictsWithNavPopupGadget' ),
sessionToken: user.sessionId(),
pageToken: generateToken(),
page: {
title: config.get( 'wgTitle' ),
namespaceID: config.get( 'wgNamespaceNumber' ),
id: config.get( 'wgArticleId' )
},
user: {
isAnon: user.isAnon(),
editCount: editCount,
previewCount: previewCount
}
};
}
/**
* Represents Page Previews fetching data via the gateway.
*
* @param {Gateway} gateway
* @param {mw.Title} title
* @param {Element} el
* @param {String} token The unique token representing the link interaction that
* triggered the fetch
* @return {Redux.Thunk}
*/
export function fetch( gateway, title, el, token ) {
var titleText = title.getPrefixedDb(),
namespaceID = title.namespace;
return function ( dispatch ) {
var request;
dispatch( timedAction( {
type: types.FETCH_START,
el: el,
title: titleText,
namespaceID: namespaceID
} ) );
request = gateway.getPageSummary( titleText )
.then( function ( result ) {
dispatch( timedAction( {
type: types.FETCH_END,
el: el
} ) );
return result;
} )
.catch( function ( err ) {
dispatch( {
type: types.FETCH_FAILED,
el: el
} );
// Keep the request promise in a rejected status since it failed.
throw err;
} );
return $.when(
request,
wait( FETCH_COMPLETE_TARGET_DELAY - FETCH_START_DELAY )
)
.then( function ( result ) {
dispatch( {
type: types.FETCH_COMPLETE,
el: el,
result: result,
token: token
} );
} )
.fail( function ( data, result ) {
// All failures, except those due to being offline or network error,
// should present "There was an issue displaying this preview".
// e.g.:
// - Show (timeout): data="http" {xhr: {…}, textStatus: "timeout",
// exception: "timeout"}
// - Show (bad MW request): data="unknown_action" {error: {…}}
// - Show (RB 4xx): data="http" {xhr: {…}, textStatus: "error",
// exception: "Bad Request"}
// - Show (RB 5xx): data="http" {xhr: {…}, textStatus: "error",
// exception: "Service Unavailable"}
// - Suppress (offline or network error): data="http"
// result={xhr: {…}, textStatus: "error", exception: ""}
var networkError = result && result.xhr &&
result.xhr.readyState === 0 && result.textStatus === 'error' &&
result.exception === '';
if ( !networkError ) {
dispatch( {
// Both FETCH_FAILED and FETCH_END conclude with FETCH_COMPLETE.
type: types.FETCH_COMPLETE,
el: el,
result: createNullModel( titleText, title.getUrl() ),
token: token
} );
}
} );
};
}
/**
* Represents the user dwelling on a link, either by hovering over it with
* their mouse or by focussing it using their keyboard or an assistive device.
*
* @param {mw.Title} title
* @param {Element} el
* @param {Event} event
* @param {Gateway} gateway
* @param {Function} generateToken
* @return {Redux.Thunk}
*/
export function linkDwell( title, el, event, gateway, generateToken ) {
var token = generateToken(),
titleText = title.getPrefixedDb(),
namespaceID = title.namespace;
return function ( dispatch, getState ) {
var action = timedAction( {
type: types.LINK_DWELL,
el: el,
event: event,
token: token,
title: titleText,
namespaceID: namespaceID
} );
// Has the new generated token been accepted?
function isNewInteraction() {
return getState().preview.activeToken === token;
}
dispatch( action );
if ( !isNewInteraction() ) {
return $.Deferred().resolve().promise();
}
return wait( FETCH_START_DELAY )
.then( function () {
var previewState = getState().preview;
if ( previewState.enabled && isNewInteraction() ) {
dispatch( fetch( gateway, title, el, token ) );
}
} );
};
}
/**
* Represents the user abandoning a link, either by moving their mouse away
* from it or by shifting focus to another UI element using their keyboard or
* an assistive device, or abandoning a preview by moving their mouse away
* from it.
*
* @return {Redux.Thunk}
*/
export function abandon() {
return function ( dispatch, getState ) {
var token = getState().preview.activeToken;
if ( !token ) {
return $.Deferred().resolve().promise();
}
dispatch( timedAction( {
type: types.ABANDON_START,
token: token
} ) );
return wait( ABANDON_END_DELAY )
.then( function () {
dispatch( {
type: types.ABANDON_END,
token: token
} );
} );
};
}
/**
* Represents the user clicking on a link with their mouse, keyboard, or an
* assistive device.
*
* @param {Element} el
* @return {Object}
*/
export function linkClick( el ) {
return timedAction( {
type: types.LINK_CLICK,
el: el
} );
}
/**
* Represents the user dwelling on a preview with their mouse.
*
* @return {Object}
*/
export function previewDwell() {
return {
type: types.PREVIEW_DWELL
};
}
/**
* Represents a preview being shown to the user.
*
* This action is dispatched by the `./changeListeners/render.js` change
* listener.
*
* @param {String} token
* @return {Object}
*/
export function previewShow( token ) {
return timedAction( {
type: types.PREVIEW_SHOW,
token: token
} );
}
/**
* Represents the user clicking either the "Enable previews" footer menu link,
* or the "cog" icon that's present on each preview.
*
* @return {Object}
*/
export function showSettings() {
return {
type: types.SETTINGS_SHOW
};
}
/**
* Represents the user closing the settings dialog and saving their settings.
*
* @return {Object}
*/
export function hideSettings() {
return {
type: types.SETTINGS_HIDE
};
}
/**
* Represents the user saving their settings.
*
* N.B. This action returns a Redux.Thunk not because it needs to perform
* asynchronous work, but because it needs to query the global state for the
* current enabled state. In order to keep the enabled state in a single
* place (the preview reducer), we query it and dispatch it as `wasEnabled`
* so that other reducers (like settings) can act on it without having to
* duplicate the `enabled` state locally.
* See doc/adr/0003-keep-enabled-state-only-in-preview-reducer.md for more
* details.
*
* @param {Boolean} enabled if previews are enabled or not
* @return {Redux.Thunk}
*/
export function saveSettings( enabled ) {
return function ( dispatch, getState ) {
dispatch( {
type: types.SETTINGS_CHANGE,
wasEnabled: getState().preview.enabled,
enabled: enabled
} );
};
}
/**
* Represents the queued event being logged `changeListeners/eventLogging.js`
* change listener.
*
* @param {Object} event
* @return {Object}
*/
export function eventLogged( event ) {
return {
type: types.EVENT_LOGGED,
event: event
};
}
/**
* Represents the queued statsv event being logged.
* See `mw.popups.changeListeners.statsv` change listener.
*
* @return {Object}
*/
export function statsvLogged() {
return {
type: types.STATSV_LOGGED
};
}