Merge remote-tracking branch 'gerrit/mpga'

Switch default branch to master

Change-Id: I4abbaae83e84a9666bbd5cef0150e25fc0fee0c6
This commit is contained in:
jdlrobson 2017-02-14 11:19:50 -08:00
commit 6908b59ccf
140 changed files with 8309 additions and 3963 deletions

20
.eslintrc.json Normal file
View file

@ -0,0 +1,20 @@
{
"extends": "wikimedia",
"env": {
"browser": true,
"jquery": true,
"commonjs": true,
"qunit": true
},
"globals": {
"mediaWiki": false,
"OO": false,
"moment": false,
"Redux": false,
"ReduxThunk": false
},
"rules": {
"dot-notation": [ "error", { "allowKeywords": true } ],
"no-use-before-define": 0
}
}

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
resources/dist/** -diff -whitespace

View file

@ -3,4 +3,5 @@ host=gerrit.wikimedia.org
port=29418
project=mediawiki/extensions/Popups.git
track=1
defaultrebase=0
defaultrebase=0
defaultbranch=master

View file

@ -1,3 +0,0 @@
{
"preset": "wikimedia"
}

View file

@ -1 +0,0 @@
node_modules

View file

@ -1,29 +0,0 @@
{
/* Common */
// Enforcing
"camelcase": true,
"curly": true,
"eqeqeq": true,
"immed": true,
"latedef": true,
"newcap": true,
"noarg": true,
"noempty": true,
"nonew": true,
"quotmark": "single",
"trailing": true,
"undef": true,
"unused": true,
// Legacy
"onevar": true,
"browser": true,
/* Local */
"predef": [
"mediaWiki",
"jQuery",
"moment",
"QUnit",
"OO"
]
}

8
.stylelintrc Normal file
View file

@ -0,0 +1,8 @@
{
"extends": "stylelint-config-wikimedia",
"rules": {
"selector-no-id": null,
"length-zero-no-unit": null,
"no-descending-specificity": null
}
}

124
Gemfile.lock Normal file
View file

@ -0,0 +1,124 @@
GEM
remote: https://rubygems.org/
specs:
ast (2.3.0)
astrolabe (1.3.1)
parser (~> 2.2)
builder (3.2.2)
childprocess (0.5.9)
ffi (~> 1.0, >= 1.0.11)
chunky_png (1.3.8)
cucumber (1.3.20)
builder (>= 2.1.2)
diff-lcs (>= 1.1.3)
gherkin (~> 2.12)
multi_json (>= 1.7.5, < 2.0)
multi_test (>= 0.1.2)
data_magic (1.0)
faker (>= 1.1.2)
yml_reader (>= 0.6)
diff-lcs (1.2.5)
dimensions (1.2.0)
domain_name (0.5.20161129)
unf (>= 0.0.5, < 1.0.0)
faker (1.6.6)
i18n (~> 0.5)
faraday (0.10.0)
multipart-post (>= 1.2, < 3)
faraday-cookie_jar (0.0.6)
faraday (>= 0.7.4)
http-cookie (~> 1.0.0)
faraday_middleware (0.10.1)
faraday (>= 0.7.4, < 1.0)
ffi (1.9.14)
gherkin (2.12.2)
multi_json (~> 1.3)
headless (2.3.1)
http-cookie (1.0.3)
domain_name (~> 0.5)
i18n (0.7.0)
jsduck (5.3.4)
dimensions (~> 1.2.0)
json (~> 1.8.0)
parallel (~> 0.7.1)
rdiscount (~> 2.1.6)
rkelly-remix (~> 0.0.4)
json (1.8.3)
mediawiki_api (0.7.0)
faraday (~> 0.9, >= 0.9.0)
faraday-cookie_jar (~> 0.0, >= 0.0.6)
faraday_middleware (~> 0.10, >= 0.10.0)
mediawiki_selenium (1.7.3)
cucumber (~> 1.3, >= 1.3.20)
headless (~> 2.0, >= 2.1.0)
json (~> 1.8, >= 1.8.1)
mediawiki_api (~> 0.7, >= 0.7.0)
page-object (~> 1.0)
rest-client (~> 1.6, >= 1.6.7)
rspec-core (~> 2.14, >= 2.14.4)
rspec-expectations (~> 2.14, >= 2.14.4)
selenium-webdriver (< 3)
syntax (~> 1.2, >= 1.2.0)
thor (~> 0.19, >= 0.19.1)
mime-types (2.99.3)
multi_json (1.12.1)
multi_test (0.1.2)
multipart-post (2.0.0)
net-http-persistent (2.9.4)
netrc (0.11.0)
page-object (1.2.2)
net-http-persistent (~> 2.9.4)
page_navigation (>= 0.9)
selenium-webdriver (>= 2.53.0)
watir-webdriver (>= 0.6.11, < 0.9.9)
page_navigation (0.10)
data_magic (>= 0.22)
parallel (0.7.1)
parser (2.3.3.1)
ast (~> 2.2)
powerpack (0.1.1)
rainbow (2.1.0)
rake (10.5.0)
rdiscount (2.1.8)
rest-client (1.8.0)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 3.0)
netrc (~> 0.7)
rkelly-remix (0.0.7)
rspec-core (2.99.2)
rspec-expectations (2.99.2)
diff-lcs (>= 1.1.3, < 2.0)
rubocop (0.29.1)
astrolabe (~> 1.3)
parser (>= 2.2.0.1, < 3.0)
powerpack (~> 0.1)
rainbow (>= 1.99.1, < 3.0)
ruby-progressbar (~> 1.4)
ruby-progressbar (1.8.1)
rubyzip (1.2.0)
selenium-webdriver (2.53.4)
childprocess (~> 0.5)
rubyzip (~> 1.0)
websocket (~> 1.0)
syntax (1.2.1)
thor (0.19.4)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.2)
watir-webdriver (0.9.3)
selenium-webdriver (>= 2.46.2)
websocket (1.2.3)
yml_reader (0.7)
PLATFORMS
ruby
DEPENDENCIES
chunky_png (~> 1.3.4)
jsduck (~> 5.3.4)
mediawiki_selenium (~> 1.7, >= 1.7.1)
rake (~> 10.4, >= 10.4.2)
rubocop (~> 0.29.1)
BUNDLED WITH
1.12.5

View file

@ -1,35 +1,88 @@
/*jshint node:true */
/* eslint-evn node */
module.exports = function ( grunt ) {
var conf = grunt.file.readJSON( 'extension.json' ),
QUNIT_URL_BASE = 'http://localhost:8080/wiki/Special:JavaScriptTest/qunit/plain';
grunt.loadNpmTasks( 'grunt-banana-checker' );
grunt.loadNpmTasks( 'grunt-contrib-jshint' );
grunt.loadNpmTasks( 'grunt-jscs' );
grunt.loadNpmTasks( 'grunt-contrib-qunit' );
grunt.loadNpmTasks( 'grunt-contrib-watch' );
grunt.loadNpmTasks( 'grunt-eslint' );
grunt.loadNpmTasks( 'grunt-jsonlint' );
grunt.loadNpmTasks( 'grunt-stylelint' );
grunt.initConfig( {
banana: {
all: 'i18n/'
},
jshint: {
options: {
jshintrc: true
banana: conf.MessagesDirs,
eslint: {
fix: {
options: {
fix: true
},
src: [
'<%= eslint.all %>'
]
},
all: [
'*.js',
'**/*.js',
'src/**',
'resources/ext.popups/*.js',
'resources/ext.popups/**/*.js',
'!resources/dist/index.js',
'!docs/**',
'!node_modules/**'
]
},
jscs: {
src: '<%= jshint.all %>'
},
jsonlint: {
all: [
'*.json',
'**/*.json',
'!docs/**',
'!node_modules/**'
]
},
qunit: {
all: {
options: {
timeout: 10000, // Using the filter query param takes longer
summaryOnly: true,
urls: [
// Execute any QUnit test in those module whose names begin with
// "ext.popups".
QUNIT_URL_BASE + '?filter=ext.popups'
]
}
}
},
stylelint: {
options: {
syntax: 'less'
},
all: [
'resources/ext.popups/**/*.less'
]
},
watch: {
options: {
interrupt: true,
debounceDelay: 1000
},
lint: {
files: [ 'resources/ext.popups/**/*.less', 'resources/**/*.js', 'tests/qunit/**/*.js' ],
tasks: [ 'lint' ]
},
scripts: {
files: [ 'resources/**/*.js', 'tests/qunit/**/*.js' ],
tasks: [ 'test' ]
},
configFiles: {
files: [ 'Gruntfile.js' ],
options: {
reload: true
}
}
}
} );
grunt.registerTask( 'test', [ 'jshint', 'jscs', 'jsonlint', 'banana' ] );
grunt.registerTask( 'default', 'test' );
grunt.registerTask( 'lint', [ 'eslint:all', 'stylelint', 'jsonlint', 'banana' ] );
grunt.registerTask( 'test', [ 'qunit' ] );
grunt.registerTask( 'default', [ 'lint', 'test' ] );
};

View file

@ -18,263 +18,167 @@
* @file
* @ingroup extensions
*/
use MediaWiki\Logger\LoggerFactory;
use Popups\PopupsContext;
class PopupsHooks {
static function getPreferences( User $user, array &$prefs ){
const PREVIEWS_PREFERENCES_SECTION = 'rendering/reading';
static function onGetBetaPreferences( User $user, array &$prefs ) {
global $wgExtensionAssetsPath;
if ( self::getConfig()->get( 'PopupsBetaFeature' ) !== true ) {
if ( PopupsContext::getInstance()->isBetaFeatureEnabled() !== true ) {
return;
}
$prefs['popups'] = array(
$prefs[PopupsContext::PREVIEWS_BETA_PREFERENCE_NAME] = [
'label-message' => 'popups-message',
'desc-message' => 'popups-desc',
'screenshot' => array(
'screenshot' => [
'ltr' => "$wgExtensionAssetsPath/Popups/images/popups-ltr.svg",
'rtl' => "$wgExtensionAssetsPath/Popups/images/popups-rtl.svg",
),
],
'info-link' => 'https://www.mediawiki.org/wiki/Beta_Features/Hovercards',
'discussion-link' => 'https://www.mediawiki.org/wiki/Talk:Beta_Features/Hovercards',
'requirements' => array(
'requirements' => [
'javascript' => true,
),
);
}
/**
* @return Config
*/
public static function getConfig() {
static $config;
if ( !$config ) {
$config = ConfigFactory::getDefaultInstance()->makeConfig( 'popups' );
}
return $config;
}
/**
* @param ResourceLoader $rl
* @return bool
*/
public static function onResourceLoaderRegisterModules( ResourceLoader $rl ) {
$moduleDependencies = array(
'mediawiki.jqueryMsg',
'mediawiki.ui.button',
'mediawiki.ui.icon',
'moment',
'jquery.hidpi',
'ext.popups.targets.desktopTarget',
'ext.popups.images',
);
// Create a schema module and add it as a dependency of `ext.popups.desktop`.
$schemaPopups = [
'remoteExtPath' => 'Popups',
'localBasePath' => __DIR__,
'targets' => [ 'desktop' ],
],
];
if ( class_exists( 'EventLogging' ) ) {
$schemaPopups += [
'dependencies' => [
'schema.Popups',
'ext.popups.schemaPopups.utils',
],
'scripts' => [
'resources/ext.popups.schemaPopups/ext.popups.schemaPopups.js',
]
];
}
$rl->register('ext.popups.schemaPopups', $schemaPopups );
$moduleDependencies[] = 'ext.popups.schemaPopups';
$rl->register( "ext.popups.desktop", array(
'scripts' => array(
'resources/ext.popups.desktop/ext.popups.renderer.article.js',
'resources/ext.popups.desktop/ext.popups.settings.js',
),
'templates' => array(
'popup.mustache' => 'resources/ext.popups.desktop/popup.mustache',
'settings.mustache' => 'resources/ext.popups.desktop/settings.mustache',
),
'styles' => array(
'resources/ext.popups.desktop/ext.popups.animation.less',
'resources/ext.popups.desktop/ext.popups.settings.less',
),
'dependencies' => $moduleDependencies,
'messages' => array(
'popups-last-edited',
"popups-settings-title",
"popups-settings-description",
"popups-settings-option-simple",
"popups-settings-option-simple-description",
"popups-settings-option-advanced",
"popups-settings-option-advanced-description",
"popups-settings-option-off",
"popups-settings-save",
"popups-settings-cancel",
"popups-settings-enable",
"popups-settings-help",
"popups-settings-help-ok",
"popups-send-feedback",
),
'remoteExtPath' => 'Popups',
'localBasePath' => __DIR__,
) );
// if MobileFrontend is installed, register mobile popups modules
if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' )
&& self::getConfig()->get( 'EnablePopupsMobile' )
) {
$mobileBoilerplate = array(
'targets' => array( 'mobile' ),
'remoteExtPath' => 'Popups',
'localBasePath' => __DIR__,
);
$rl->register( 'ext.popups.targets.mobileTarget', array(
'dependencies' => array(
'ext.popups.core',
'ext.popups.renderer.mobileRenderer',
),
'scripts' => array(
'resources/ext.popups.targets.mobileTarget/mobileTarget.js',
),
) + $mobileBoilerplate
);
$rl->register( 'ext.popups.renderer.mobileRenderer', array(
'dependencies' => array(
'ext.popups.core',
'mobile.startup',
),
'scripts' => array(
'resources/ext.popups.renderer.mobileRenderer/mobileRenderer.js',
'resources/ext.popups.renderer.mobileRenderer/LinkPreviewDrawer.js',
),
'templates' => array(
'LinkPreviewDrawer.hogan' => 'resources/ext.popups.renderer.mobileRenderer/LinkPreviewDrawer.hogan',
),
'styles' => array(
'resources/ext.popups.renderer.mobileRenderer/LinkPreview.less',
),
'messages' => array(
'popups-mobile-continue-to-page',
'popups-mobile-dismiss',
),
) + $mobileBoilerplate
);
}
return true;
}
public static function onBeforePageDisplay( OutputPage &$out, Skin &$skin) {
// Enable only if the user has turned it on in Beta Preferences, or BetaFeatures is not installed.
// Will only be loaded if PageImages & TextExtracts extensions are installed.
/**
* Add Page Previews options to user Preferences page
*
* @param User $user
* @param array $prefs
*/
static function onGetPreferences( User $user, array &$prefs ) {
$context = PopupsContext::getInstance();
$registry = ExtensionRegistry::getInstance();
if ( !$registry->isLoaded( 'TextExtracts' ) || !class_exists( 'ApiQueryPageImages' ) ) {
$logger = LoggerFactory::getInstance( 'popups' );
$logger->error( 'Popups requires the PageImages and TextExtracts extensions.' );
if ( !$context->showPreviewsOptInOnPreferencesPage() ) {
return;
}
$option = [
'type' => 'radio',
'label-message' => 'popups-prefs-optin-title',
'help' => wfMessage( 'popups-prefs-conflicting-gadgets-info' ),
'options' => [
wfMessage( 'popups-prefs-optin-enabled-label' )->text()
=> PopupsContext::PREVIEWS_ENABLED,
wfMessage( 'popups-prefs-optin-disabled-label' )->text()
=> PopupsContext::PREVIEWS_DISABLED
],
'section' => self::PREVIEWS_PREFERENCES_SECTION
];
if ( $context->conflictsWithNavPopupsGadget( $user ) ) {
$option[ 'disabled' ] = true;
$option[ 'help' ] = wfMessage( 'popups-prefs-disable-nav-gadgets-info',
'Special:Preferences#mw-prefsection-gadgets' );
}
$skinPosition = array_search( 'skin', array_keys( $prefs ) );
if ( $skinPosition !== false ) {
$injectIntoIndex = $skinPosition + 1;
$prefs = array_slice( $prefs, 0, $injectIntoIndex, true )
+ [ PopupsContext::PREVIEWS_OPTIN_PREFERENCE_NAME => $option ]
+ array_slice( $prefs, $injectIntoIndex, count( $prefs ) - 1, true );
} else {
$prefs[ PopupsContext::PREVIEWS_OPTIN_PREFERENCE_NAME ] = $option;
}
}
public static function onBeforePageDisplay( OutputPage &$out, Skin &$skin ) {
$module = PopupsContext::getInstance();
$user = $out->getUser();
if ( !$module->areDependenciesMet() ) {
$logger = $module->getLogger();
$logger->error( 'Popups requires the PageImages and TextExtracts extensions. '
. 'If Beta mode is on it requires also BetaFeatures extension' );
return true;
}
$config = self::getConfig();
$isExperimentEnabled = $config->get( 'PopupsExperiment' );
if (
// If Popups are enabled as an experiment, then bypass checking whether the user has enabled
// it as a beta feature.
!$isExperimentEnabled &&
$config->get( 'PopupsBetaFeature' ) === true
) {
if ( !class_exists( 'BetaFeatures' ) ) {
$logger = LoggerFactory::getInstance( 'popups' );
$logger->error( 'PopupsMode cannot be used as a beta feature unless ' .
'the BetaFeatures extension is present.' );
return true;
}
if ( !BetaFeatures::isFeatureEnabled( $skin->getUser(), 'popups' ) ) {
return true;
}
if ( !$module->isBetaFeatureEnabled() || $module->shouldSendModuleToUser( $user ) ) {
$out->addModules( [ 'ext.popups' ] );
}
$out->addModules( array( 'ext.popups.desktop' ) );
return true;
}
/**
* Handler for MobileFrontend's BeforePageDisplay hook, which is only called in mobile mode.
*
* @param OutputPage &$out,
* @param Skin &$skin
*/
public static function onBeforePageDisplayMobile( OutputPage &$out, Skin &$skin ) {
// enable mobile link preview in mobile beta and if the beta feature is enabled
if (
self::getConfig()->get( 'EnablePopupsMobile' ) &&
MobileContext::singleton()->isBetaGroupMember()
) {
$out->addModules( 'ext.popups.targets.mobileTarget' );
}
}
/**
* @param array &$testModules
* @param ResourceLoader $resourceLoader
* @return bool
*/
public static function onResourceLoaderTestModules( array &$testModules, ResourceLoader &$resourceLoader ) {
$testModules['qunit']['ext.popups.tests'] = array(
'scripts' => array(
'tests/qunit/ext.popups.desktopRenderer.test.js',
'tests/qunit/ext.popups.renderer.article.test.js',
'tests/qunit/ext.popups.core.test.js',
'tests/qunit/ext.popups.schemaPopups.utils.test.js',
'tests/qunit/ext.popups.settings.test.js',
'tests/qunit/ext.popups.experiment.test.js',
),
'dependencies' => array(
'ext.popups.desktop',
'ext.popups.schemaPopups.utils'
),
public static function onResourceLoaderTestModules( array &$testModules,
ResourceLoader &$resourceLoader ) {
$localBasePath = __DIR__;
$scripts = glob( "{$localBasePath}/tests/qunit/ext.popups/{,**/}*.test.js", GLOB_BRACE );
$start = strlen( $localBasePath ) + 1;
$scripts = array_map( function ( $script ) use ( $start ) {
return substr( $script, $start );
}, $scripts );
$testModules['qunit']['ext.popups.tests.stubs'] = [
'scripts' => [
'tests/qunit/ext.popups/stubs/index.js',
'tests/qunit/ext.popups/stubs/user.js',
],
'dependencies' => [
'ext.popups', // The mw.popups is required.
],
'localBasePath' => __DIR__,
'remoteExtPath' => 'Popups',
);
return true;
];
$testModules['qunit']['ext.popups.tests'] = [
'scripts' => $scripts,
'dependencies' => [
'ext.popups',
'ext.popups.tests.stubs',
],
'localBasePath' => __DIR__,
'remoteExtPath' => 'Popups',
];
}
/**
* @param array $vars
*/
public static function onResourceLoaderGetConfigVars( array &$vars ) {
$conf = ConfigFactory::getDefaultInstance()->makeConfig( 'popups' );
$vars['wgPopupsSurveyLink'] = $conf->get( 'PopupsSurveyLink' );
$vars['wgPopupsSchemaPopupsSamplingRate'] = $conf->get( 'SchemaPopupsSamplingRate' );
if ( $conf->get( 'PopupsExperiment' ) ) {
$vars['wgPopupsExperiment'] = true;
$vars['wgPopupsExperimentConfig'] = $conf->get( 'PopupsExperimentConfig' );
}
$conf = PopupsContext::getInstance()->getConfig();
$vars['wgPopupsSchemaSamplingRate'] = $conf->get( 'PopupsSchemaSamplingRate' );
$vars['wgPopupsBetaFeature'] = $conf->get( 'PopupsBetaFeature' );
$vars['wgPopupsAPIUseRESTBase'] = $conf->get( 'PopupsAPIUseRESTBase' );
}
/**
* MakeGlobalVariablesScript hook handler.
*
* @see https://www.mediawiki.org/wiki/Manual:Hooks/MakeGlobalVariablesScript
* Variables added:
* * `wgPopupsShouldSendModuleToUser' - The server's notion of whether or not the
* user has enabled Page Previews (see `\Popups\PopupsContext#shouldSendModuleToUser`).
* * `wgPopupsConflictsWithNavPopupGadget' - The server's notion of whether or not the
* user has enabled conflicting Navigational Popups Gadget.
*
* @param array $vars
* @param OutputPage $out
*/
public static function onMakeGlobalVariablesScript( array &$vars, OutputPage $out ) {
$config = ConfigFactory::getDefaultInstance()->makeConfig( 'popups' );
$module = PopupsContext::getInstance();
$user = $out->getUser();
if ( $config->get( 'PopupsExperiment' ) ) {
$vars['wgPopupsExperimentIsBetaFeatureEnabled'] =
class_exists( 'BetaFeatures' ) && BetaFeatures::isFeatureEnabled( $user, 'popups' );
}
$vars['wgPopupsShouldSendModuleToUser'] = $module->shouldSendModuleToUser( $user );
$vars['wgPopupsConflictsWithNavPopupGadget'] = $module->conflictsWithNavPopupsGadget(
$user );
}
/**
* Register default preferences for popups
*
* @param array $wgDefaultUserOptions Reference to default options array
*/
public static function onUserGetDefaultOptions( &$wgDefaultUserOptions ) {
$wgDefaultUserOptions[ PopupsContext::PREVIEWS_OPTIN_PREFERENCE_NAME ] =
PopupsContext::getInstance()->getDefaultIsEnabledState();
}
}

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# mediawiki/extensions/Popups
See https://www.mediawiki.org/wiki/Extension:Popups for more information about
what it does.
## Development
Popups uses an asset bundler so when developing for the extension you'll need
to run a script to assemble the frontend assets.
You can find the frontend source files in `src/`, the compiled sources in
`resources/dist/`, and other frontend assets managed by resource loader in
`resources/*`.
After an `npm install`:
* `npm start` Will run the bundler in watch mode, re-assembling the files on
file change.
* `npm run build` Will compile the assets just once, ready for deployment. You
*must* run this step before sending the patch or CI will fail (so that
sources and built assets are in sync).
* `npm test` To run the linting tools and the tests.

View file

@ -0,0 +1,20 @@
# 1. Record architecture decisions
Date: 09/11/2016
## Status
Accepted
## Context
We need to record the architectural decisions made on this project.
## Decision
We will use Architecture Decision Records, as described by Michael Nygard in
[this article](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions).
## Consequences
See Michael Nygard's article, linked above.

View file

@ -0,0 +1,33 @@
# 2. Contain and manage state
Date: 09/11/2016
## Status
Accepted
## Context
The hardest part of debugging Page Previews issues (especially those related to
EventLogging) was understanding the state of the system (the "state") and how
it's mutated given some interaction(s). This was in no small part because the
state was defined, initialized, and mutated in various parts of the codebase.
The state required for Page Previews to function isn't actually overly
complicated. To keep things easy to debug/easy to reason about we should
endeavour to isolate the state and its mutations from the various other parts of
the system.
## Decision
Use [Redux](http://redux.js.org).
## Consequences
* Newcomers will have to familiarise themselves with the library (especially
it's nomenclature).
* All changes will have to be broken down into: the additional state that they
require; actions that are dispatched; and how the state is mutated as a result
of those actions. This requires discipline.
* We benefit from using a tool that is part of an increasingly rich ecosystem,
e.g. see [Redux DevTools Extension](https://github.com/zalmoxisus/redux-devtools-extension).

View file

@ -0,0 +1,78 @@
# 3. Keep enabled state only in preview reducer
Date: 14/12/2016
## Status
Accepted
## Context
Discussed by Sam Smith and Joaquin Oltra.
There is global state for determining if the previews are enabled or disabled.
It lives in the `preview` reducer as the `enabled` key.
As part of implementing the settings, it was noticed that:
* When the settings are saved in the UI dialog,
* And a `SETTINGS_CHANGE` action is triggered with the new `enabled` state,
* Then the settings reducer (`reducers/settings.js`) also needs to know about
the previous `enabled` state to determine if it:
* should ignore the change
* hide the UI
* or show the help if the user is disabling
Given the enabled state was kept in the `preview` reducer, there are several
options considered:
1. Add an `enabled` property to the `settings` reducer, duplicating that part
of the state in both `preview` and `settings` reducers.
* **Pros**
* `saveSettings` action creator remains synchronous
* `settings` reducer internally contains all it needs to act on actions
* **Cons**
* `enabled` state, action handling and state toggling, and tests are
duplicated in both `reducers/preview` and `reducers/settings`
* Maintenance overhead
* Confusion about which of both to use for taking decisions in other parts
of the source
* Risk of the `enabled` flags getting out of sync with future changes
2. Rely on UI dialog captured state to trigger `saveSettings` with the current
and new state.
* **Pros**
* `saveSettings` action creator remains synchronous
* `settings` reducer gets via action all it needs to act on actions
* **Cons**
* `enabled` state is duplicate in the `preview` reducer and in the created
settings dialog (either as a captured variable, or as DOM state)
* Confusion about which of both to use for taking decisions in other parts
of the source
* Risk of the `enabled` state on the UI getting out of sync with future
changes, and triggering stale state with the action resulting in bugs
3. Keep the `enabled` flag in the `preview` reducer as the canonical source of
`enabled` state. Convert `saveSettings` to a `Redux.Thunk`, to query global
state, and then dispatch current and next state in the action.
* **Pros**
* `enabled` state exists in just one place in the whole application. No
duplication
* `settings` reducer gets via action all it needs to act on actions
* **Cons**
* Confusion about the use of a `Redux.Thunk` on a synchronous action creator,
where in the docs they are used only for asynchronous action creators
## Decision
After code review and discussing the different options and trade-offs, the
implementation of **`3`** was chosen mainly because of the clarity that having one
piece of state just in one place brings for understanding the application, and
the benefits on maintainability.
Extensive documentation and tests have been written in the action creator and
in this document to explain the choice made, which should overcome the cons.
## Consequences
The `saveSettings` action creator is now a `Redux.Thunk`, which uses `getState`
to query the enabled state in the `preview` reducer, and adds it to the
`SETTINGS_CHANGE` action as `wasEnabled`. As such, the `settings` reducer can
act on `SETTINGS_CHANGE` to perform its business logic.

View file

@ -0,0 +1,63 @@
# 4. Use webpack
Date: 02/02/2017
## Status
Accepted
## Context
Discussed by entire team, but predominately Sam Smith, Joaquin Hernandez and
Jon Robson.
As our JavaScript becomes more complex we are making it increasingly difficult
to maintain dependencies via extension.json. Dependencies and file order have
to be managed and every new file creation requires an edit to extension.json.
This slows down development. In Vagrant for instance NTFS file systems
experience slowdown when loading many files.
There are many tools that bundle JavaScript out there that can do this for us.
** Pros **
* mw.popups no longer needs to be exposed as a global object
* Dependency management is no longer a manual process but automated by webpack
* Would allow us to explore template pre-compiling
* More reliable debug via source map support
* For non-MediaWiki developers it should be easier to understand our
development workflow.
**Cons**
* There is now a build step. New developers to the extension may try to
directly edit the distribution files.
* Likely to be more merge conflicts, but this could be addressed by additional
tooling (e.g. post-merge build step)
## Decision
There are various bundlers to choose from, but Webpack was chosen on the basis
that
1) It was easy to switch to another
2) It is popular and well maintained.
3) Many members of the team are familiar with it.
https://medium.com/@tomchentw/why-webpack-is-awesome-9691044b6b8e#.mi0mmz75y
provides a good write up.
## Consequences
While we migrate directory structure is likely to go through a series of
changes. Specifically template loading is likely to change in future.
New JavaScript files should import and export other files via commonjs and
not rely on global variables.
extension.json still needs to be updated to point to modules in MediaWiki
core.
Care should be taken when including node module libraries to ensure they
are not loaded by other extensions.
Developers working on the repository are now required to run `npm run build`
in a pre-commit hook to ensure that the right JavaScript is sent to users.

View file

@ -0,0 +1,43 @@
# 1. Frontend sources directory structure
Date: 14/02/2017
## Status
Accepted
## Context
With the addition of a frontend bundler, there are now assets that are the
source, and assets that are for distribution.
Before, all assets were distribution ones stored in `resources/`, just
a configurable convention used by the Reading Web team for using MediaWiki's
ResourceLoader.
In order to facilitate the CI checks and understanding where sources are and
where compiled sources are, we need to chose two distinct paths for storing
sources and distribution files.
## Decision
After some discussion, because of ease of understanding to the wider
development community and the good mapping between the name and what they
contain, we chose to:
* Put unbundled frontend sources in `src/`.
* Put bundled distribution files in `dist/` under `resources/` in
`resources/dist/`.
* Files directly distributed by ResourceLoader remain under `resources/*` to
follow Reading Web Team's conventions around assets used by ResourceLoader.
## Consequences
* Frontend sources will be under `src/`.
* After `npm start` or `npm run build` the bundled sources will be under
`resources/dist`.
* Jenkins will check in continuous integration that the sources under `src/`
are actually compiled when commited under `resources/dist`.
* If the `src` path where to become inconvenient because we wanted to add other
types of sources in it, we'll move the frontend assets to `src/js` or
something more specific.

35
doc/change_listener.md Normal file
View file

@ -0,0 +1,35 @@
# Change Listeners
Redux's [`Store#subscribe`](http://redux.js.org/docs/api/Store.html#subscribe)
allows you to subscribe to updates to the state tree. These updates are
delivered every time an action is dispatched to the store, which may or may not
result in a change of state.
In the Page Previews codebase, a **change listener** is a function that is only
called when the state tree has changed. As such, change listeners are
predominantly responsible for updating the UI so that it matches the state in
the store.
## Registering Change Listeners
**Change listeners** are registered automatically during
[boot](./resources/ext.popups/boot.js) in the `registerChangeListeners`
function. It expects the values of the `mw.popups.changeListeners` map to be
factory functions that accept, currently, the [bound action
creators](http://redux.js.org/docs/api/bindActionCreators.html), i.e.
```javascript
mw.popups.changeListeners.foo = function ( boundActions ) {
var $link = $( '<a>' )
.attr( 'href': '#' )
.click( boundActions.showSettings );
return function ( prevState, state ) {
// ...
}
};
```
You'll note that the above **change listener** is effectful and maintains some
local state (`$link`), both of which are acceptable. The former is unavoidable
and the latter is to avoid populating the state tree with unimportant data.

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

23
doc/instrumentation.md Normal file
View file

@ -0,0 +1,23 @@
# Instrumentation
Page Previews is thoroughly instrumented. Currently, there's one [Event Logging](https://www.mediawiki.org/wiki/Extension:EventLogging) ("EL") schema that captures all of the data that we record about a user's interactions with the Page Previews extension, the [Schema:Popups](https://meta.wikimedia.org/wiki/Schema:Popups) schema.
Tilman Bayer captured the high level state and user action's that should trigger an event to be logged via EL [here](https://www.mediawiki.org/wiki/File:State_diagram_for_Schema-Popups_(Hovercards_instrumentation).svg) indeed, this diagram was a catalyst for rewriting the Page Previews application as a large finite state machine.
## Implementation
Events need to be queued and dequeued in response to [actions](http://redux.js.org/docs/basics/Actions.html) dispatched to the store. This could be implemented in either a [Redux middleware](http://redux.js.org/docs/advanced/Middleware.html) or as a [reducer](http://redux.js.org/docs/basics/Reducers.html), an [action](http://redux.js.org/docs/basics/Actions.html), and a [change listener](./change_listener.md). Both approaches satisfy the general requirement that instrumentation should be transparent to the rest of the codebase but the latter is the approach we're taking for the rest of the application and instrumentation isn't a special case. Moreover, given the amount of time it took to get the original instrumentation under test, we can leverage the constraint the [reducers](http://redux.js.org/docs/basics/Reducers.html) must be pure to test the majority of the instrumentation logic in isolation.
Since the event data varies with the value of the `action` property, events are represented by a blob of `action`-specific data and a blob of data that's shared between all events. Very nearly all of the latter can and should be initialized when the Page Previews application boots.
### Data Flow
![data_flow](./images/instrumentation/data_flow.jpg)
When enqueuing and logging an event, data flows between the reducer and the change listener as follows:
1. The state is initialized to `null`..
2. An event is enqueued by the reducer as a result of an action.
3. The change listener sees that the state tree has changed and logs the queued event via `mw.eventLog.Schema#log`.
4. The change listener dispatches the `EVENT_LOGGED` action.
5. The reducer resets the state (read: `GOTO 1`).

View file

@ -9,14 +9,16 @@
"license-name": "GPL-2.0+",
"type": "betafeatures",
"AutoloadClasses": {
"PopupsHooks": "Popups.hooks.php"
"PopupsHooks": "Popups.hooks.php",
"Popups\\PopupsContext": "includes/PopupsContext.php",
"Popups\\PopupsGadgetsIntegration": "includes/PopupsGadgetsIntegration.php"
},
"ConfigRegistry": {
"popups": "GlobalVarConfig::newInstance"
},
"Hooks": {
"GetBetaFeaturePreferences": [
"PopupsHooks::getPreferences"
"PopupsHooks::onGetBetaPreferences"
],
"BeforePageDisplay": [
"PopupsHooks::onBeforePageDisplay"
@ -24,14 +26,14 @@
"ResourceLoaderTestModules": [
"PopupsHooks::onResourceLoaderTestModules"
],
"ResourceLoaderRegisterModules": [
"PopupsHooks::onResourceLoaderRegisterModules"
],
"ResourceLoaderGetConfigVars": [
"PopupsHooks::onResourceLoaderGetConfigVars"
],
"BeforePageDisplayMobile": [
"PopupsHooks::onBeforePageDisplayMobile"
"GetPreferences": [
"PopupsHooks::onGetPreferences"
],
"UserGetDefaultOptions": [
"PopupsHooks::onUserGetDefaultOptions"
],
"MakeGlobalVariablesScript": [
"PopupsHooks::onMakeGlobalVariablesScript"
@ -43,55 +45,24 @@
]
},
"EventLoggingSchemas": {
"Popups": 15906495
"Popups": 16208085
},
"config": {
"@PopupsBetaFeature": "@var bool: Whether the extension should be enabled as an opt-in beta feature. If true, the BetaFeatures extension must be installed. False by default.",
"PopupsBetaFeature": false,
"@PopupsSurveyLink": "@var bool|string: When defined a link will be rendered at the bottom of the popup for the user to provide feedback. The URL must start with https or http. If not, then an error is thrown client-side. The link is annotated with `rel=\"noreferrer\"` so no referrer information or `window.opener` is leaked to the survey hosting site (see https://html.spec.whatwg.org/multipage/semantics.html#link-type-noreferrer for more information).",
"PopupsSurveyLink": false,
"EnablePopupsMobile": false,
"@SchemaPopupsSamplingRate": "@var number: Sample rate for logging events to Schema:Popups.",
"SchemaPopupsSamplingRate": 0,
"PopupsExperiment": false
},
"DefaultUserOptions": {
"popupsmobile": "1"
"@PopupsSchemaSamplingRate": "@var number: Sample rate for logging events to Schema:Popups.",
"PopupsSchemaSamplingRate": 0,
"@PopupsHideOptInOnPreferencesPage": "@var bool: Whether the option to senable/disable Page Previews should be hidden on Preferences page. Please note if PopupsBetaFeature is set to true this option will be always hidden. False by default",
"PopupsHideOptInOnPreferencesPage": false,
"@PopupsOptInDefaultState" : "@var string:['1'|'0'] Default Page Previews visibility. Has to be a string as a compatibility with beta feature settings",
"PopupsOptInDefaultState" : "0",
"@PopupsConflictingNavPopupsGadgetName": "@var string: Navigation popups gadget name",
"PopupsConflictingNavPopupsGadgetName": "Navigation_popups",
"PopupsConflictingNavPopupsGadgetName": "Navigation_popups",
"@PopupsAPIUseRESTBase": "Whether to use RESTBase rather than the MediaWiki API for fetching Popups data.",
"PopupsAPIUseRESTBase": false
},
"ResourceModules": {
"ext.popups.core": {
"scripts": [
"resources/ext.popups.core/ext.popups.core.js"
],
"dependencies": [
"mediawiki.api",
"mediawiki.Title",
"mediawiki.Uri",
"mediawiki.RegExp",
"mediawiki.storage",
"mediawiki.user",
"mediawiki.experiments"
],
"targets": [
"desktop",
"mobile"
],
"styles": [
"resources/ext.popups.core/ext.popups.core.less"
]
},
"ext.popups.targets.desktopTarget": {
"scripts": [
"resources/ext.popups.targets.desktopTarget/desktopTarget.js"
],
"dependencies": [
"mediawiki.storage",
"jquery.client",
"ext.popups.core",
"ext.popups.renderer.desktopRenderer"
],
"targets": [ "desktop" ]
},
"ext.popups.images": {
"selector": ".mw-ui-icon-{name}:before",
"class": "ResourceLoaderImageModule",
@ -100,27 +71,47 @@
"popups-close": "resources/ext.popups.images/close.svg"
}
},
"ext.popups.renderer.desktopRenderer": {
"ext.popups": {
"scripts": [
"resources/ext.popups.renderer.desktopRenderer/desktopRenderer.js"
"resources/dist/index.js"
],
"templates": {
"preview.mustache": "resources/ext.popups/templates/preview.mustache",
"preview-empty.mustache": "resources/ext.popups/templates/preview-empty.mustache",
"settings.mustache": "resources/ext.popups/templates/settings.mustache"
},
"styles": [
"resources/ext.popups/styles/ext.popups.core.less",
"resources/ext.popups/styles/ext.popups.animation.less",
"resources/ext.popups/styles/ext.popups.settings.less"
],
"messages": [
"popups-settings-title",
"popups-settings-description",
"popups-settings-option-simple",
"popups-settings-option-simple-description",
"popups-settings-option-advanced",
"popups-settings-option-advanced-description",
"popups-settings-option-off",
"popups-settings-save",
"popups-settings-cancel",
"popups-settings-enable",
"popups-settings-help",
"popups-settings-help-ok",
"popups-send-feedback",
"popups-preview-no-preview",
"popups-preview-footer-read"
],
"dependencies": [
"ext.popups.core"
]
},
"ext.popups.schemaPopups.utils": {
"scripts": [
"resources/ext.popups.schemaPopups.utils/ext.popups.schemaPopups.utils.js"
],
"dependencies": [
"mediawiki.experiments",
"ext.popups.images",
"mediawiki.storage",
"mediawiki.Title",
"mediawiki.user",
"ext.popups.core",
"ext.popups.renderer.desktopRenderer"
],
"targets": [
"desktop"
"mediawiki.jqueryMsg",
"mediawiki.ui.button",
"mediawiki.ui.icon",
"mediawiki.Uri",
"jquery.hidpi",
"ext.eventLogging.Schema"
]
}
},

View file

@ -4,7 +4,6 @@
},
"popups-message": "Hovercards",
"popups-desc": "Displays hovercards with summaries of page contents when the user hovers over a page link",
"popups-last-edited": "Edited $1",
"popups-redirects": "redirects to <h3>$1</h3>",
"popups-settings-title": "Page preview",
"popups-settings-description": "This tool lets you preview links to wiki pages and to references.",
@ -19,7 +18,12 @@
"popups-settings-help": "You can turn previews back on using a link in the footer of the page.",
"popups-settings-enable": "Enable previews",
"popups-send-feedback": "Send Feedback (external link)",
"popups-mobile-continue-to-page": "Continue to page",
"popups-mobile-dismiss": "Dismiss",
"popups-mobile-message": "Hovercards (mobile)"
"popups-preview-no-preview": "Looks like there isn't a preview for this page",
"popups-preview-footer-read": "Read",
"prefs-reading": "Reading preferences",
"popups-prefs-optin-title": "Page previews\n\n<em>Get quick previews of a topic while reading an article</em>",
"popups-prefs-optin-enabled-label": "Enable",
"popups-prefs-optin-disabled-label": "Disable",
"popups-prefs-disable-nav-gadgets-info": "You have to [[$1 | disable Navigation Popups Gadget]] from Gadgets tab to enable Page Previews",
"popups-prefs-conflicting-gadgets-info": "Certain gadgets and other customizations may affect the performance of this feature. If you experience problems please review your gadgets and user scripts."
}

View file

@ -10,7 +10,6 @@
},
"popups-message": "Name shown in user preference for this extension",
"popups-desc": "{{desc|name=Popups|url=https://www.mediawiki.org/wiki/Extension:Popups}}",
"popups-last-edited": "Message to show time span since the page was last edited. Need not include the word \"last\". Parameters:\n* $1 - the timespan in words (localized). e.g. \"3 months ago\"",
"popups-redirects": "Unused at this time.\n\nMessage shown when the hovercard is showing a redirected page.\n\nParameters:\n* $1 - Redirect target page",
"popups-settings-title": "Title used for the setting's dialog",
"popups-settings-description": "Description for the setting's dialog",
@ -25,7 +24,12 @@
"popups-settings-help": "Help text explaining how to re-enable previews",
"popups-settings-enable": "Link on the footer to enable hovercards if its disabled.\n\nSee also:\n* {{msg-mw|Popups-settings-option-off}}",
"popups-send-feedback": "Tooltip for the send feedback icon on the hovercard. Should mention that its an external link.\n{{Identical|Send feedback}}",
"popups-mobile-continue-to-page": "Button label visible in the link preview drawer. When clicked, the browser redirects to the target page.",
"popups-mobile-dismiss": "Label of button that dismisses the link preview overlay.\n{{Identical|Dismiss}}",
"popups-mobile-message": "Name shown in user preference for the mobile hovercards feature of this extension"
"popups-preview-no-preview": "The message shown to the user when a preview can't be generated.",
"popups-preview-footer-read": "The link shown to the user when a preview can't be generated.",
"prefs-reading": "Title for 'Reading preferences' section on preferences page",
"popups-prefs-optin-title": "Title for Page Previews option\n\n<em>Description for Page previews option</em>",
"popups-prefs-optin-enabled-label": "Label for Page Previews opt in",
"popups-prefs-optin-disabled-label": "Label for Page previews opt out",
"popups-prefs-disable-nav-gadgets-info": "Help message telling to disable Navigation Popups gadget in order to enable Page Previews. Parameters: $1 - link to Preferences page",
"popups-prefs-conflicting-gadgets-info": "Help message informing about possible conflicts with other gadgets/customizations"
}

190
includes/PopupsContext.php Normal file
View file

@ -0,0 +1,190 @@
<?php
/*
* This file is part of the MediaWiki extension Popups.
*
* Popups is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* Popups is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Popups. If not, see <http://www.gnu.org/licenses/>.
*
* @file
* @ingroup extensions
*/
namespace Popups;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use ExtensionRegistry;
use Config;
/**
* Popups Module
*
* @package Popups
*/
class PopupsContext {
/**
* Extension name
* @var string
*/
const EXTENSION_NAME = 'popups';
/**
* Logger channel (name)
* @var string
*/
const LOGGER_CHANNEL = 'popups';
/**
* User preference value for enabled Page Previews
* @var string
*/
const PREVIEWS_ENABLED = \HTMLFeatureField::OPTION_ENABLED;
/**
* User preference value for disabled Page Previews
* @var string
*/
const PREVIEWS_DISABLED = \HTMLFeatureField::OPTION_DISABLED;
/**
* User preference to enable/disable Page Previews
* Currently for BETA and regular opt in we use same preference name
*
* @var string
*/
const PREVIEWS_OPTIN_PREFERENCE_NAME = 'popups';
/**
* User preference to enable/disable Page Preivews as a beta feature
* @var string
*/
const PREVIEWS_BETA_PREFERENCE_NAME = 'popups';
/**
* @var \Config
*/
private $config;
/**
* @var PopupsContext
*/
protected static $instance;
/**
* Module constructor.
* @param Config $config
* @param ExtensionRegistry $extensionRegistry
* @param PopupsGadgetsIntegration $gadgetsIntegration
*/
protected function __construct( Config $config, ExtensionRegistry $extensionRegistry,
PopupsGadgetsIntegration $gadgetsIntegration ) {
/** @todo Use MediaWikiServices Service Locator when it's ready */
$this->extensionRegistry = $extensionRegistry;
$this->gadgetsIntegration = $gadgetsIntegration;
$this->config = $config;
}
/**
* Get a PopupsContext instance
*
* @return PopupsContext
*/
public static function getInstance() {
if ( !self::$instance ) {
/** @todo Use MediaWikiServices Service Locator when it's ready */
$registry = ExtensionRegistry::getInstance();
$config = MediaWikiServices::getInstance()->getConfigFactory()
->makeConfig( PopupsContext::EXTENSION_NAME );
$gadgetsIntegration = new PopupsGadgetsIntegration( $config, $registry );
self::$instance = new PopupsContext( $config, $registry, $gadgetsIntegration );
}
return self::$instance;
}
/**
* @param \User $user
* @return bool
*/
public function conflictsWithNavPopupsGadget( \User $user ) {
return $this->gadgetsIntegration->conflictsWithNavPopupsGadget( $user );
}
/**
* Is Beta Feature mode enabled
*
* @return bool
*/
public function isBetaFeatureEnabled() {
return $this->config->get( 'PopupsBetaFeature' ) === true;
}
/**
* Get default Page previews state
*
* @see PopupsContext::PREVIEWS_ENABLED
* @see PopupsContext::PREVIEWS_DISABLED
* @return string
*/
public function getDefaultIsEnabledState() {
return $this->config->get( 'PopupsOptInDefaultState' );
}
/**
* Are Page previews visible on User Preferences Page
*
* return @bool
*/
public function showPreviewsOptInOnPreferencesPage() {
return !$this->isBetaFeatureEnabled()
&& $this->config->get( 'PopupsHideOptInOnPreferencesPage' ) === false;
}
/**
* @param \User $user
* @return bool
*/
public function shouldSendModuleToUser( \User $user ) {
if ( $this->isBetaFeatureEnabled() ) {
return $user->isAnon() ? false :
\BetaFeatures::isFeatureEnabled( $user, self::PREVIEWS_BETA_PREFERENCE_NAME );
}
return $user->isAnon() ? true :
$user->getOption( self::PREVIEWS_OPTIN_PREFERENCE_NAME ) === self::PREVIEWS_ENABLED;
}
/**
* @return bool
*/
public function areDependenciesMet() {
$areMet = $this->extensionRegistry->isLoaded( 'TextExtracts' )
&& $this->extensionRegistry->isLoaded( 'PageImages' );
if ( $this->isBetaFeatureEnabled() ) {
$areMet = $areMet && $this->extensionRegistry->isLoaded( 'BetaFeatures' );
}
return $areMet;
}
/**
* Get module logger
*
* @return \Psr\Log\LoggerInterface
*/
public function getLogger() {
return LoggerFactory::getInstance( self::LOGGER_CHANNEL );
}
/**
* Get Module config
*
* @return \Config
*/
public function getConfig() {
return $this->config;
}
}

View file

@ -0,0 +1,81 @@
<?php
/*
* This file is part of the MediaWiki extension Popups.
*
* Popups is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* Popups is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Popups. If not, see <http://www.gnu.org/licenses/>.
*
* @file
* @ingroup extensions
*/
namespace Popups;
use Config;
use ExtensionRegistry;
/**
* Gadgets integration
*
* @package Popups
*/
class PopupsGadgetsIntegration {
/**
* @var string
*/
const CONFIG_NAVIGATION_POPUPS_NAME = 'PopupsConflictingNavPopupsGadgetName';
/**
* @var \ExtensionRegistry
*/
private $extensionRegistry;
/**
* @var string
*/
private $navPopupsGadgetName;
/**
* PopupsGadgetsIntegration constructor.
*
* @param Config $config
* @param ExtensionRegistry $extensionRegistry
*/
public function __construct( Config $config , ExtensionRegistry $extensionRegistry ) {
$this->extensionRegistry = $extensionRegistry;
$this->navPopupsGadgetName = $config->get( self::CONFIG_NAVIGATION_POPUPS_NAME );
}
/**
* @return bool
*/
private function isGadgetExtensionEnabled() {
return $this->extensionRegistry->isLoaded( 'Gadgets' );
}
/**
* Check if Page Previews conflicts with Nav Popups Gadget
* If user enabled Nav Popups PagePreviews are not available
*
* @param \User $user
* @return bool
*/
public function conflictsWithNavPopupsGadget( \User $user ) {
if ( $this->isGadgetExtensionEnabled() ) {
$gadgetsRepo = \GadgetRepo::singleton();
$match = array_search( $this->navPopupsGadgetName, $gadgetsRepo->getGadgetIds() );
if ( $match !== false ) {
return $gadgetsRepo->getGadget( $this->navPopupsGadgetName )->isEnabled( $user );
}
}
return false;
}
}

View file

@ -1,15 +1,25 @@
{
"private": true,
"scripts": {
"test": "grunt test",
"doc": "jsduck"
"start": "webpack -w",
"build": "webpack",
"test": "grunt lint && npm run check-built-assets",
"doc": "jsduck",
"check-built-assets": "echo 'CHECKING BUILD SOURCES ARE COMMITED' && rm -rf test-build && mv resources/dist test-build && npm run build && diff -x '*.map' -qr test-build resources/dist && rm -rf test-build"
},
"devDependencies": {
"grunt": "0.4.5",
"grunt-banana-checker": "0.4.0",
"grunt-cli": "0.1.13",
"grunt-contrib-jshint": "0.11.3",
"grunt-jscs": "2.1.0",
"grunt-jsonlint": "1.0.7"
"eslint-config-wikimedia": "0.3.0",
"grunt": "1.0.1",
"grunt-banana-checker": "0.5.0",
"grunt-cli": "^1.2.0",
"grunt-contrib-qunit": "^1.2.0",
"grunt-contrib-watch": "^1.0.0",
"grunt-eslint": "19.0.0",
"grunt-jsonlint": "1.1.0",
"grunt-stylelint": "^0.6.0",
"redux": "3.6.0",
"redux-thunk": "2.2.0",
"stylelint-config-wikimedia": "0.3.0",
"webpack": "^1.14.0"
}
}

BIN
resources/dist/index.js vendored Normal file

Binary file not shown.

BIN
resources/dist/index.js.map vendored Normal file

Binary file not shown.

View file

@ -1,333 +0,0 @@
( function ( $, mw ) {
'use strict';
var previewCountStorageKey = 'ext.popups.core.previewCount',
popupsEnabledStorageKey = 'mwe-popups-enabled';
/**
* @class mw.popups
* @singleton
*/
mw.popups = {};
/**
* The API object used for all this extension's requests
* @property {Object} api
*/
mw.popups.api = new mw.Api();
/**
* Whether the page is being scrolled.
* @property {boolean} scrolled
*/
mw.popups.scrolled = false;
/**
* List of classes of which links are ignored
* @property {Array} IGNORE_CLASSES
*/
mw.popups.IGNORE_CLASSES = [
'.extiw',
'.image',
'.new',
'.internal',
'.external',
'.oo-ui-buttonedElement-button',
'.cancelLink a'
];
/**
* Temporarily remove the title attribute of a link so that
* the tooltip doesn't show up alongside the Hovercard.
*
* @method removeTooltip
* @param {jQuery.Object} $link link from which to strip title
*/
mw.popups.removeTooltip = function ( $link ) {
// We shouldn't empty the title attribute of links that
// can't have Hovercards, ie. TextExtracts didn't return
// anything. It's set in the article.init after attempting
// to make an API request.
if (
$link.data( 'dont-empty-title' ) !== true &&
$link.filter( '[title]:not([title=""])' ).length
) {
$link
.data( 'title', $link.attr( 'title' ) )
.attr( 'title', '' );
}
};
/**
* Restore previously-removed title attribute.
*
* @method restoreTooltip
* @param {jQuery.Object} $link link to which to restore title
*/
mw.popups.restoreTooltip = function ( $link ) {
$link.attr( 'title', $link.data( 'title' ) );
};
/**
* Register a hover event that may render a popup on an appropriate link.
*
* @method setupTriggers
* @param {jQuery.Object} $elements to bind events to
* @param {string} events to bind to
*/
mw.popups.setupTriggers = function ( $elements, events ) {
$elements.on( events, function ( event ) {
if ( mw.popups.scrolled ) {
return;
}
mw.popups.render.render( $( this ), event, mw.popups.getRandomToken() );
} );
};
/**
* Given an href string for the local wiki, return the title, or undefined if
* the link is external, has extra query parameters, or contains no title.
*
* Note that the returned title is not sanitized (may contain underscores).
*
* @param {string} href
* @return {string|undefined}
*/
mw.popups.getTitle = function ( href ) {
var title, titleRegex, matches, linkHref;
// Skip every URI that mw.Uri cannot parse
try {
linkHref = new mw.Uri( href );
} catch ( e ) {
return undefined;
}
// External links
if ( linkHref.host !== location.hostname ) {
return undefined;
}
if ( linkHref.query.hasOwnProperty( 'title' ) ) {
// linkHref is not a pretty URL, e.g. /w/index.php?title=Foo
title = linkHref.query.title;
// Return undefined if there are query parameters other than title
delete linkHref.query.title;
return $.isEmptyObject( linkHref.query ) ? title : undefined;
} else {
// linkHref is a pretty URL, e.g. /wiki/Foo
// Return undefined if there are any query parameters
if ( !$.isEmptyObject( linkHref.query ) ) {
return undefined;
}
titleRegex = new RegExp( mw.RegExp.escape( mw.config.get( 'wgArticlePath' ) )
.replace( '\\$1', '(.+)' ) );
matches = titleRegex.exec( linkHref.path );
return matches ? decodeURIComponent( matches[ 1 ] ) : undefined;
}
};
/**
* Returns links that can have Popups
*
* @method selectPopupElements
*/
mw.popups.selectPopupElements = function () {
var contentNamespaces = mw.config.get( 'wgContentNamespaces' );
return mw.popups.$content
.find( 'a[href][title]:not(' + mw.popups.IGNORE_CLASSES.join( ', ' ) + ')' )
.filter( function () {
var title,
titleText = mw.popups.getTitle( this.href );
if ( !titleText ) {
return false;
}
// Is titleText in a content namespace?
title = mw.Title.newFromText( titleText );
return title && contentNamespaces.indexOf( title.namespace ) !== -1;
} );
};
/**
* Get action based on click event
*
* @method getAction
* @param {Object} event
* @return {string}
*/
mw.popups.getAction = function ( event ) {
if ( event.which === 2 ) { // middle click
return 'opened in new tab';
} else if ( event.which === 1 ) {
if ( event.ctrlKey || event.metaKey ) {
return 'opened in new tab';
} else if ( event.shiftKey ) {
return 'opened in new window';
} else {
return 'opened in same tab';
}
}
};
/**
* Get a random token.
* Append the current timestamp to make the return value more unique.
*
* @return {string}
*/
mw.popups.getRandomToken = function () {
return mw.user.generateRandomSessionId() + Math.round( mw.now() ).toString();
};
/**
* Return edit count bucket based on the number of edits.
* The returned value is "unknown" is `window.localStorage` is not supported.
*
* @return {string}
*/
mw.popups.getPreviewCountBucket = function () {
var bucket,
previewCount = mw.storage.get( previewCountStorageKey );
// no support for localStorage
if ( previewCount === false ) {
return 'unknown';
}
// Fall back to 0 if this is the first time.
previewCount = parseInt( previewCount || 0, 10 );
if ( previewCount === 0 ) {
bucket = '0';
} else if ( previewCount >= 1 && previewCount <= 4 ) {
bucket = '1-4';
} else if ( previewCount >= 5 && previewCount <= 20 ) {
bucket = '5-20';
} else if ( previewCount >= 21 ) {
bucket = '21+';
}
return bucket + ' previews';
};
/**
* Increment the preview count and save it to localStorage.
*/
mw.popups.incrementPreviewCount = function () {
var previewCount = parseInt( mw.storage.get( previewCountStorageKey ) || 0, 10 );
mw.storage.set( previewCountStorageKey, ( previewCount + 1 ).toString() );
};
/**
* Save the popups enabled state via device storage
*
* @param {boolean} isEnabled
*/
mw.popups.saveEnabledState = function ( isEnabled ) {
mw.storage.set( popupsEnabledStorageKey, isEnabled ? '1' : '0' );
};
/**
* Retrieve the popups enabled state via device storage or 'wgPopupsExperiment'
* config variable.
* If the experiment isn't running, then continue to enable Popups
* by default during initialisation. In this case the return value
* defaults to `true` if the setting hasn't been saved before.
*
* @return {boolean}
*/
mw.popups.getEnabledState = function () {
if ( !mw.config.get( 'wgPopupsExperiment', false ) ) {
return mw.storage.get( popupsEnabledStorageKey ) !== '0';
} else {
return this.isUserInCondition();
}
};
/**
* @ignore
*/
function getToken() {
var key = 'PopupsExperimentID',
id = mw.storage.get( key );
if ( !id ) {
id = mw.user.generateRandomSessionId();
mw.storage.set( key, id );
}
return id;
}
/**
* Has the user previously enabled Popups by clicking "Enable previews" in the
* footer menu?
*
* @return {boolean}
* @ignore
*/
function hasUserEnabledFeature() {
var value = mw.storage.get( 'mwe-popups-enabled' );
return Boolean( value ) && value !== '0';
}
/**
* Has the user previously disabled Popups by clicking "Disable previews" in the settings
* overlay?
*
* @return {boolean}
* @ignore
*/
function hasUserDisabledFeature() {
return mw.storage.get( 'mwe-popups-enabled' ) === '0';
}
/**
* Gets whether or not the user has Popups enabled, i.e. whether they are in the experiment
* condition.
*
* The user is in the experiment condition if:
* * they've enabled Popups by click "Enable previews" in the footer menu, or
* * they've enabled Popups as a beta feature, or
* * they aren't in the control bucket of the experiment
*
* N.B. that the user isn't entered into the experiment, i.e. they aren't assigned or a bucket,
* if the experiment isn't configured.
*
* @return {boolean}
* @ignore
*/
mw.popups.isUserInCondition = function isUserInCondition() {
var config = mw.config.get( 'wgPopupsExperimentConfig' );
// The first two tests deal with whether the user has /explicitly/ enable or disabled via its
// settings.
if ( hasUserEnabledFeature() ) {
return true;
}
if ( hasUserDisabledFeature() ) {
return false;
}
if ( mw.user.isAnon() ) {
if ( !config ) {
return false;
}
// FIXME: mw.experiments should expose the CONTROL_BUCKET constant, e.g.
// `mw.experiments.CONTROL_BUCKET`.
return mw.experiments.getBucket( config, getToken() ) !== 'control';
} else {
// Logged in users are in condition depending on the beta feature flag
// instead of bucketing
return mw.config.get( 'wgPopupsExperimentIsBetaFeatureEnabled', false );
}
};
} )( jQuery, mediaWiki );

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 B

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<g>
<path fill="#555555" d="M3,15l3,0.6V18c0,0.6,0.4,1,1,1h5c0.6,0,1-0.4,1-1v-1l5,1V7L3,11C2.6,11.5,2.6,14.4,3,15z M7,15.8l5,1v0.7
c0,0.3-0.2,0.5-0.5,0.5h-4C7.2,18,7,17.8,7,17.5V15.8z"/>
<path fill="#555555" d="M20,10h-1v5h1c0.6,0,1-0.4,1-1v-3C21,10.4,20.6,10,20,10z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 B

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<g>
<path fill="#555555" d="M21,11L6,7v11l5-1v1c0,0.6,0.4,1,1,1h5c0.6,0,1-0.4,1-1v-2.4l3-0.6C21.4,14.4,21.4,11.5,21,11z M17,17.5
c0,0.3-0.2,0.5-0.5,0.5h-4c-0.3,0-0.5-0.2-0.5-0.5v-0.7l5-1V17.5z"/>
<path fill="#555555" d="M3,11v3c0,0.6,0.4,1,1,1h1v-5H4C3.4,10,3,10.4,3,11z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 638 B

View file

@ -1,660 +0,0 @@
( function ( $, mw ) {
'use strict';
/**
* @class mw.popups.render.article
* @singleton
*/
var currentRequest,
isSafari = navigator.userAgent.match( /Safari/ ) !== null,
article = {},
surveyLink = mw.config.get( 'wgPopupsSurveyLink' ),
$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 ) );
if ( surveyLink ) {
$div.find( 'footer' ).append( article.createSurveyLink( surveyLink ) );
}
mw.popups.render.cache[ href ].settings = {
title: page.title,
namespace: page.ns,
tall: ( tall === undefined ) ? false : tall,
thumbnail: ( thumbnail === undefined ) ? false : thumbnail
};
return $div;
};
/**
* Creates a link to a survey, possibly hosted on an external site.
*
* @param {string} url
* @return {jQuery}
*/
article.createSurveyLink = function ( url ) {
if ( !/https?:\/\//.test( url ) ) {
throw new Error(
'The survey link URL, i.e. PopupsSurveyLink, must start with https or http.'
);
}
return $( '<a>' )
.attr( 'href', url )
.attr( 'target', '_blank' )
.attr( 'title', mw.message( 'popups-send-feedback' ) )
// Don't leak referrer information or `window.opener` to the survey hosting site. See
// https://html.spec.whatwg.org/multipage/semantics.html#link-type-noreferrer for more
// information.
.attr( 'rel', 'noreferrer' )
.addClass( 'mwe-popups-icon mwe-popups-survey-icon' );
};
/**
* 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 );

View file

@ -1,186 +0,0 @@
( function ( $, mw ) {
var currentLinkLogData,
/**
* @class mw.popups.settings
* @singleton
*/
settings = {};
/**
* The settings' dialog's section element.
* Defined in settings.open
* @property $element
*/
settings.$element = null;
/**
* Renders the relevant form and labels in the settings dialog
*
* @method render
*/
settings.render = function () {
var path = mw.config.get( 'wgExtensionAssetsPath' ) + '/Popups/resources/ext.popups.desktop/',
choices = [
{
id: 'simple',
name: mw.message( 'popups-settings-option-simple' ).text(),
description: mw.message( 'popups-settings-option-simple-description' ).text(),
image: path + 'images/hovercard.svg',
isChecked: true
},
{
id: 'advanced',
name: mw.message( 'popups-settings-option-advanced' ).text(),
description: mw.message( 'popups-settings-option-advanced-description' ).text(),
image: path + 'images/navpop.svg'
},
{
id: 'off',
name: mw.message( 'popups-settings-option-off' ).text()
}
];
// Check if NavigationPopups is enabled
/*global pg: false*/
if ( typeof pg === 'undefined' || pg.fn.disablePopups === undefined ) {
// remove the advanced option
choices.splice( 1, 1 );
}
// render the template
settings.$element = mw.template.get( 'ext.popups.desktop', 'settings.mustache' ).render( {
heading: mw.message( 'popups-settings-title' ).text(),
closeLabel: mw.message( 'popups-settings-cancel' ).text(),
saveLabel: mw.message( 'popups-settings-save' ).text(),
helpText: mw.message( 'popups-settings-help' ).text(),
okLabel: mw.message( 'popups-settings-help-ok' ).text(),
descriptionText: mw.message( 'popups-settings-description' ).text(),
choices: choices
} );
// setup event bindings
settings.$element.find( '.save' ).click( settings.save );
settings.$element.find( '.close' ).click( settings.close );
settings.$element.find( '.okay' ).click( function () {
settings.close();
settings.reloadPage();
} );
$( 'body' ).append( settings.$element );
};
/**
* Save the setting to the device and close the dialog
*
* @method save
*/
settings.save = function () {
var v = $( 'input[name=mwe-popups-setting]:checked', '#mwe-popups-settings' ).val();
if ( v === 'simple' ) {
mw.popups.saveEnabledState( true );
settings.reloadPage();
settings.close();
} else {
mw.popups.saveEnabledState( false );
$( '#mwe-popups-settings-form, #mwe-popups-settings .save' ).hide();
$( '#mwe-popups-settings-help, #mwe-popups-settings .okay' ).show();
mw.track( 'ext.popups.event', $.extend( {}, currentLinkLogData, {
action: 'disabled'
} ) );
}
};
/**
* Show the settings element and position it correctly
*
* @method open
* @param {Object} logData data to log
*/
settings.open = function ( logData ) {
var
h = $( window ).height(),
w = $( window ).width();
currentLinkLogData = logData;
$( 'body' ).append( $( '<div>' ).addClass( 'mwe-popups-overlay' ) );
if ( !settings.$element ) {
settings.render();
}
// FIXME: Should recalc on browser resize
settings.$element
.show()
.css( 'left', ( w - settings.$element.outerWidth( true ) ) / 2 )
.css( 'top', ( h - settings.$element.outerHeight( true ) ) / 2 );
return false;
};
/**
* Close the setting dialog and remove the overlay.
* If the close button is clicked on the help dialog
* save the setting and reload the page.
*
* @method close
*/
settings.close = function () {
if ( $( '#mwe-popups-settings-help' ).is( ':visible' ) ) {
settings.reloadPage();
} else {
$( '.mwe-popups-overlay' ).remove();
settings.$element.hide();
}
};
/**
* Adds a link to the footer to re-enable hovercards
*
* @method addFooterLink
*/
settings.addFooterLink = function () {
var $setting, $footer;
if ( mw.popups.enabled ) {
return false;
}
$setting = $( '<li>' ).append(
$( '<a>' )
.attr( 'href', '#' )
.text( mw.message( 'popups-settings-enable' ).text() )
.click( function ( e ) {
settings.open();
e.preventDefault();
} )
);
$footer = $( '#footer-places, #f-list' );
// From https://en.wikipedia.org/wiki/MediaWiki:Gadget-ReferenceTooltips.js
if ( $footer.length === 0 ) {
$footer = $( '#footer li' ).parent();
}
$footer.append( $setting );
};
/**
* Wrapper around window.location.reload. Exposed for testing purposes only.
*
* @private
* @ignore
*/
settings.reloadPage = function () {
location.reload();
};
$( function () {
if ( !mw.popups.enabled ) {
settings.addFooterLink();
}
} );
mw.popups.settings = settings;
} )( jQuery, mediaWiki );

View file

@ -1,12 +0,0 @@
<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>

View file

@ -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 );

View file

@ -1,52 +0,0 @@
@import "minerva.variables";
.drawer.linkpreview {
background-color: #fff;
position: fixed;
padding: 0 15px 20px;
text-align: left;
&.loading {
padding: 10px;
}
}
.linkpreview-overlay {
background-color: rgba( 0, 0, 0, 0.1 );
background-attachment: fixed;
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index: @z-indexOverlay;
}
.linkpreview-title {
font-family: @fontFamilyHeading;
font-size: 22px;
margin-top: 20px;
line-height: 1;
}
.linkpreview-content {
font-size: 15px;
margin-top: 20px;
}
.linkpreview-actions {
margin-top: 20px;
text-align: right;
a {
margin-bottom: 0 !important;
margin-top: 0;
}
}
@media all and ( min-width: @deviceWidthTablet ) {
.drawer.linkpreview {
padding-left: 30px;
padding-right: 30px;
}
}

View file

@ -1,6 +0,0 @@
<div class="linkpreview">
<div class="linkpreview-title"></div>
<div class="linkpreview-content"></div>
<div class="linkpreview-actions">{{#dismissButton}}{{>Button}}{{/dismissButton}}{{#continueButton}}{{>Button}}{{/continueButton}}</div>
</div>
{{{spinner}}}

View file

@ -1,215 +0,0 @@
( function ( mw, M, $, OO ) {
var Drawer = M.require( 'mobile.startup/Drawer' ),
Button = M.require( 'mobile.startup/Button' ),
icons = M.require( 'mobile.startup/icons' );
/**
* Drawer for the link preview feature on a mobile device
*
* @class LinkPreviewDrawer
* @extends Drawer
*/
function LinkPreviewDrawer() {
Drawer.apply( this, arguments );
}
OO.mfExtend( LinkPreviewDrawer, Drawer, {
/**
* @cfg {Object} defaults Default options hash.
* @cfg {string} defaults.spinner html of spinner icon
* @cfg {Object} defaults.continueButton HTML of the continue button.
*/
defaults: {
spinner: icons.spinner().toHtmlString(),
continueButton: new Button( {
progressive: true,
label: mw.msg( 'popups-mobile-continue-to-page' ),
additionalClassNames: 'linkpreview-continue'
} ).options,
dismissButton: new Button( {
label: mw.msg( 'popups-mobile-dismiss' ),
additionalClassNames: 'linkpreview-dismiss'
} ).options
},
/**
* @inheritdoc
*/
template: mw.template.get( 'ext.popups.renderer.mobileRenderer', 'LinkPreviewDrawer.hogan' ),
/**
* @inheritdoc
*/
templatePartials: {
Button: Button.prototype.template
},
/**
* @inheritdoc
*/
events: $.extend( {}, Drawer.prototype.events, {
'click .linkpreview-continue, .linkpreview-title': 'onContinueClick',
'click .linkpreview-dismiss': 'hide'
} ),
/**
* @inheritdoc
*/
className: 'drawer linkpreview',
/**
* @inheritdoc
*/
closeOnScroll: false,
/**
* Cache for the api queries.
*
* @property {Object}
*/
cache: {},
/**
* @inheritdoc
*/
postRender: function () {
var $linkpreviewOverlay;
Drawer.prototype.postRender.apply( this, arguments );
$linkpreviewOverlay = $( '<div>' )
.addClass( 'linkpreview-overlay hidden' );
this.$el.before( $linkpreviewOverlay );
this.$linkpreview = this.$( '.linkpreview' );
this.$spinner = this.$( '.spinner' );
},
/**
* @inheritdoc
*/
show: function () {
$( '.linkpreview-overlay' ).removeClass( 'hidden' );
Drawer.prototype.show.apply( this, arguments );
},
/**
* @inheritdoc
*/
hide: function () {
Drawer.prototype.hide.apply( this, arguments );
$( '.linkpreview-overlay' ).addClass( 'hidden' );
},
/**
* Show the drawer with the initial spinner (and hide the content,
* that was (maybe) already added.
*
* @return {Object} this
*/
showWithSpinner: function () {
this.$spinner.removeClass( 'hidden' );
this.$el.addClass( 'loading' );
this.$linkpreview.addClass( 'hidden' );
this.show();
return this;
},
/**
* Hide the spinner (if any) and show the content of the drawer.
*
* @return {Object} this
*/
showContent: function () {
this.$spinner.addClass( 'hidden' );
this.$el.removeClass( 'loading' );
this.$linkpreview.removeClass( 'hidden' );
return this;
},
/**
* Load content from a given title, show it or redirect to this
* title if something went wrong.
*
* @param {string} title The target title
*/
loadNew: function ( title ) {
var self = this;
this.showWithSpinner();
this.title = title;
if ( !this.cache[ title ] ) {
mw.popups.api.get( {
action: 'query',
titles: title,
prop: 'extracts',
explaintext: true,
exintro: true,
exchars: 140,
formatversion: 2
}, {
headers: {
'X-Analytics': 'preview=1'
}
} ).done( function ( result ) {
var data;
if ( result.query.pages[ 0 ] ) {
data = result.query.pages[ 0 ];
self
.setTitle( data.title )
.setContent( data.extract )
.showContent();
self.cache[ title ] = data;
} else {
self.onContinueClick();
}
} ).fail( function () {
self.onContinueClick();
} );
} else {
self
.setTitle( this.cache[ title ].title )
.setContent( this.cache[ title ].extract )
.showContent();
}
},
/**
* Replace the current visible title with the given one.
*
* @param {string} title The new title (HTML isn't supported)
* @return {Object} this
*/
setTitle: function ( title ) {
this.$( '.linkpreview-title' ).text( title );
return this;
},
/**
* Replace the current visible content with the given one.
*
* @param {string} content The new content (HTML isn't supported)
* @return {Object} this
*/
setContent: function ( content ) {
this.$( '.linkpreview-content' ).text( content );
return this;
},
/**
* Handle the click on the "continue to article" button and redirect to the
* title (this.title).
*/
onContinueClick: function () {
window.location.href = mw.util.getUrl( this.title );
}
} );
M.define( 'ext.popups.mobilelinkpreview/LinkPreviewDrawer', LinkPreviewDrawer );
}( mediaWiki, mediaWiki.mobileFrontend, jQuery, OO ) );

View file

@ -1,31 +0,0 @@
( function ( $, mw, M ) {
/**
* @class mw.popups.render
* @singleton
*/
mw.popups.render = {};
/**
* Render a new LinkPreviewDrawer
*
* @method render
* @param {jQuery.Object} $link that a hovercard should be shown for
* @param {jQuery.Event} event that triggered the render
*/
mw.popups.render.render = function ( $link, event ) {
var LinkPreviewDrawer = M.require( 'ext.popups.mobilelinkpreview/LinkPreviewDrawer' );
// Ignore if its meant to call a function
// TODO: Remove this when adding reference popups
if ( $link.attr( 'href' ) === '#' ) {
return;
}
if ( !mw.popups.$popup ) {
mw.popups.$popup = new LinkPreviewDrawer();
}
mw.popups.$popup.loadNew( event.target.title );
event.preventDefault();
};
} )( jQuery, mediaWiki, mediaWiki.mobileFrontend );

View file

@ -1,165 +0,0 @@
( function ( $, mw ) {
var dwellStartTime, perceivedWait;
/**
* Return data that will be logged with each EL request
*
* @return {Object}
*/
function getDefaultValues() {
var defaults = {
pageTitleSource: mw.config.get( 'wgTitle' ),
namespaceIdSource: mw.config.get( 'wgNamespaceNumber' ),
pageIdSource: mw.config.get( 'wgArticleId' ),
isAnon: mw.user.isAnon(),
hovercardsSuppressedByGadget: false,
popupEnabled: mw.popups.getEnabledState(),
popupDelay: mw.popups.render.POPUP_DELAY,
pageToken: mw.user.generateRandomSessionId() +
Math.floor( mw.now() ).toString() +
mw.user.generateRandomSessionId(),
sessionToken: mw.user.sessionId(),
// arbitrary name that represents the current UI of the popups
version: 'legacy',
// current API version
api: 'mwapi'
};
// Include edit count bucket to the list of default values if the user is logged in.
if ( !mw.user.isAnon() ) {
defaults.editCountBucket = mw.popups.schemaPopups.getEditCountBucket(
mw.config.get( 'wgUserEditCount' ) );
}
return defaults;
}
/**
* Return the sampling rate for the Schema:Popups
*
* User's session ID is used to determine the eligibility for logging,
* thus the function will result the same outcome as long as the browser
* hasn't been restarted or the cookie hasn't been cleared.
*
* @return {number}
*/
function getSamplingRate() {
var bucket,
samplingRate = mw.config.get( 'wgPopupsSchemaPopupsSamplingRate', 0 );
if ( !$.isFunction( navigator.sendBeacon ) ) {
return 0;
}
bucket = mw.experiments.getBucket( {
name: 'ext.popups.schemaPopus',
enabled: true,
buckets: {
control: 1 - samplingRate,
A: samplingRate
}
}, mw.user.sessionId() );
return bucket === 'A' ? 1 : 0;
}
/**
* Return edit count bucket based on the number of edits
*
* @param {number} editCount
* @return {string}
*/
function getEditCountBucket( editCount ) {
var bucket;
if ( editCount === 0 ) {
bucket = '0';
} else if ( editCount >= 1 && editCount <= 4 ) {
bucket = '1-4';
} else if ( editCount >= 5 && editCount <= 99 ) {
bucket = '5-99';
} else if ( editCount >= 100 && editCount <= 999 ) {
bucket = '100-999';
} else if ( editCount >= 1000 ) {
bucket = '1000+';
}
return bucket + ' edits';
}
/**
* Checks whether the event signals the end of a hovercards lifecycle
*
* @param {string} action
* @return {boolean}
*/
function isFinalLifeCycleEvent( action ) {
return [ 'dwelledButAbandoned', 'opened in new window', 'dismissed',
'opened in new window', 'opened in same tab' ].indexOf( action ) > -1;
}
/**
* Return data after making some adjustments so that it's ready to be logged
* Returns false if the event should not be logged based on its contents or previous logged data
*
* @param {Object} data
* @param {Object} previousLogData
* @return {Object|boolean}
*/
function processHovercardEvent( data, previousLogData ) {
// We don't log hover and display events as they are not compatible with the schema
// but they are useful for debugging
var action = data.action;
if ( dwellStartTime ) {
// Calculate the perceived wait to show the hovercard (currently unused)
// or the time elapsed before the user abandoned their hover
if ( action === 'display' ) {
perceivedWait = Math.round( mw.now() - dwellStartTime );
} else {
if ( perceivedWait ) {
data.perceivedWait = perceivedWait;
}
data.totalInteractionTime = Math.round( mw.now() - dwellStartTime );
}
}
// Keep track of dwell time - a hover event should always be the first event in the hovercard lifecycle
if ( !dwellStartTime && action === 'hover' ) {
dwellStartTime = mw.now();
perceivedWait = false;
} else if ( isFinalLifeCycleEvent( action ) ) {
// reset dwell start time to allow a new hover event to begin
dwellStartTime = false;
}
if ( action && [ 'hover', 'display' ].indexOf( action ) > -1 ) {
return false;
// Only one action is recorded per link interaction token...
} else if ( data.linkInteractionToken ) {
// however, the 'disabled' action takes two clicks by nature, so allow it
if ( previousLogData && data.linkInteractionToken === previousLogData.linkInteractionToken &&
action !== 'disabled'
) {
return false;
// and a dwelled but abandoned event must following an event which has a dwell start
} else if ( !data.totalInteractionTime && action === 'dwelledButAbandoned' ) {
return false;
}
}
data.previewCountBucket = mw.popups.getPreviewCountBucket();
// Figure out `namespaceIdHover` from `pageTitleHover`.
if ( data.pageTitleHover && data.namespaceIdHover === undefined ) {
data.namespaceIdHover = new mw.Title( data.pageTitleHover ).getNamespaceId();
}
return data;
}
mw.popups.schemaPopups = {
getDefaultValues: getDefaultValues,
getSamplingRate: getSamplingRate,
getEditCountBucket: getEditCountBucket,
processHovercardEvent: processHovercardEvent
};
} )( jQuery, mediaWiki );

View file

@ -1,19 +0,0 @@
( function ( mw ) {
var previousLogData,
// Log the popup event as defined in the schema
// https://meta.wikimedia.org/wiki/Schema:Popups
schemaPopups = new mw.eventLog.Schema(
'Popups',
mw.popups.schemaPopups.getSamplingRate(),
mw.popups.schemaPopups.getDefaultValues()
);
mw.trackSubscribe( 'ext.popups.event', function ( topic, data ) {
data = mw.popups.schemaPopups.processHovercardEvent( data, previousLogData );
if ( data ) {
schemaPopups.log( data );
}
previousLogData = data;
} );
} )( mediaWiki );

View file

@ -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 );

View file

@ -1,10 +0,0 @@
( function ( $, mw ) {
// FIXME: There should be a way to turn this off
mw.popups.enabled = true;
mw.hook( 'wikipage.content' ).add( function ( $content ) {
mw.popups.$content = $content;
mw.popups.setupTriggers( mw.popups.selectPopupElements(), 'click' );
} );
} )( jQuery, mediaWiki );

View file

Before

Width:  |  Height:  |  Size: 425 B

After

Width:  |  Height:  |  Size: 425 B

View file

Before

Width:  |  Height:  |  Size: 921 B

After

Width:  |  Height:  |  Size: 921 B

View file

Before

Width:  |  Height:  |  Size: 288 B

After

Width:  |  Height:  |  Size: 288 B

View file

Before

Width:  |  Height:  |  Size: 911 B

After

Width:  |  Height:  |  Size: 911 B

View file

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 303 B

View file

Before

Width:  |  Height:  |  Size: 923 B

After

Width:  |  Height:  |  Size: 923 B

View file

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 421 KiB

View file

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="37px" height="27px" viewBox="0 0 37 27" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: sketchtool 41.2 (35397) - http://www.bohemiancoding.com/sketch -->
<title>A8787B18-ECB6-43B0-A67A-1BAFC81FF21F</title>
<desc>Created with sketchtool.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="no-page-preview-available" transform="translate(-392.000000, -222.000000)" fill="#C8CCD1">
<path d="M397.475,222.7 L397.475,242.775 L392,248.25 L423.025,248.25 C426.1275,248.25 428.5,245.877502 428.5,242.775 L428.5,222.7 L397.475,222.7 L397.475,222.7 Z M417.915,227.2625 C419.1925,227.2625 420.105,228.3575 420.105,229.4525 C420.105,230.5475 419.1925,231.825 417.915,231.825 C416.6375,231.825 415.725,230.73 415.725,229.635 C415.725,228.54 416.82,227.2625 417.915,227.2625 L417.915,227.2625 Z M408.06,227.2625 C409.3375,227.2625 410.25,228.3575 410.25,229.4525 C410.25,230.5475 409.155,231.825 408.06,231.825 C406.965,231.825 405.87,230.73 405.87,229.635 C405.87,228.54 406.7825,227.2625 408.06,227.2625 L408.06,227.2625 Z M412.9875,235.475 C405.835421,235.475 404.573289,242.486842 404.573289,242.486842 C404.573289,242.486842 407.378026,241.084474 412.9875,241.084474 C418.596974,241.084474 421.401711,242.486842 421.401711,242.486842 C421.401711,242.486842 419.999342,235.475 412.9875,235.475 L412.9875,235.475 Z" id="Page-1"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,11 +1,11 @@
@import "mediawiki.mixins.animation";
@import 'mediawiki.mixins.animation';
.mwe-popups-translate(@x, @y) {
-webkit-transform: translate(@x, @y);
-moz-transform: translate(@x, @y);
-ms-transform: translate(@x, @y);
-o-transform: translate(@x, @y);
transform: translate(@x, @y);
.mwe-popups-translate( @x, @y ) {
-webkit-transform: translate( @x, @y );
-moz-transform: translate( @x, @y );
-ms-transform: translate( @x, @y );
-o-transform: translate( @x, @y );
transform: translate( @x, @y );
}
/* FIXME: Use Phuedx's approach to make this cleaner
@ -26,7 +26,7 @@
.mwe-popups-fade-in-up-frames;
}
@-webkit-keyframes mwe-popups-fade-in-down{
@-webkit-keyframes mwe-popups-fade-in-down {
.mwe-popups-fade-in-down-frames;
}
@ -77,63 +77,63 @@
.mwe-popups-fade-in-up-frames() {
0% {
opacity: 0;
.mwe-popups-translate(0, 20px);
.mwe-popups-translate( 0, 20px );
}
100% {
opacity: 1;
.mwe-popups-translate(0, 0);
.mwe-popups-translate( 0, 0 );
}
}
.mwe-popups-fade-in-down-frames() {
0% {
opacity: 0;
.mwe-popups-translate(0, -20px);
.mwe-popups-translate( 0, -20px );
}
100% {
opacity: 1;
.mwe-popups-translate(0, 0);
.mwe-popups-translate( 0, 0 );
}
}
.mwe-popups-fade-out-down-frames() {
0% {
opacity: 1;
.mwe-popups-translate(0, 0);
.mwe-popups-translate( 0, 0 );
}
100% {
opacity: 0;
.mwe-popups-translate(0, 20px);
.mwe-popups-translate( 0, 20px );
}
}
.mwe-popups-fade-out-up-frames() {
0% {
opacity: 1;
.mwe-popups-translate(0, 0);
.mwe-popups-translate( 0, 0 );
}
100% {
opacity: 0;
.mwe-popups-translate(0, -20px);
.mwe-popups-translate( 0, -20px );
}
}
.mwe-popups-fade-in-up {
.animation(mwe-popups-fade-in-up, 0.3s, ease, forwards);
.animation( mwe-popups-fade-in-up, 0.2s, ease, forwards );
}
.mwe-popups-fade-in-down {
.animation(mwe-popups-fade-in-down, 0.3s, ease, forwards);
.animation( mwe-popups-fade-in-down, 0.2s, ease, forwards );
}
.mwe-popups-fade-out-down {
.animation(mwe-popups-fade-out-down, 0.15s, ease, forwards);
.animation( mwe-popups-fade-out-down, 0.2s, ease, forwards );
}
.mwe-popups-fade-out-up {
.animation(mwe-popups-fade-out-up, 0.15s, ease, forwards);
.animation( mwe-popups-fade-out-up, 0.2s, ease, forwards );
}

View file

@ -1,5 +1,5 @@
@import "mediawiki.mixins.less";
@import "mediawiki.ui/variables";
@import 'mediawiki.mixins.less';
@import 'mediawiki.ui/variables';
/* Code adapted from Yair Rand's NavPopupsRestyled.js
* https://en.wikipedia.org/wiki/User:Yair_rand/NavPopupsRestyled.js
@ -10,7 +10,7 @@
top: -1000px;
}
.mwe-popups-border-triangle-top(@size, @left, @color, @extra) {
.mwe-popups-border-triangle-top( @size, @left, @color, @extra ) {
content: '';
position: absolute;
border: ( @size + @extra ) solid transparent;
@ -21,7 +21,7 @@
left: @left;
}
.mwe-popups-border-triangle-bottom(@size, @left, @color, @extra) {
.mwe-popups-border-triangle-bottom( @size, @left, @color, @extra ) {
content: '';
position: absolute;
border: ( @size + @extra ) solid transparent;
@ -55,57 +55,44 @@
}
.mwe-popups-settings-icon {
.background-image-svg( "images/cog.svg", "images/cog.png" );
// N.B. filenames are relative to the LESS file.
.background-image-svg( '../images/cog.svg', '../images/cog.png' );
}
.mwe-popups-survey-icon {
.background-image-svg( "images/horn-ltr.svg", "images/horn-ltr.png" );
.mwe-popups-sade-face-icon {
display: block;
width: 37px;
height: 27px;
margin: 16px 16px 0;
background-position: center center;
background-repeat: no-repeat;
.background-image-svg( '../images/sad-face.svg', '../images/sad-face.png' );
}
.mwe-popups {
position: absolute;
z-index: 110;
background: #fff;
border: 1px solid #bbb;
.box-shadow(0 0 10px rgba( 0, 0, 0, 0.2 ));
// FIXME: The .box-shadow mixin provided by mediawiki.mixins doesn't support
// multiple values.
-webkit-box-shadow: 0 30px 90px -20px rgba( 0, 0, 0, 0.3 ), 0px 0px 1px rgba( 0, 0, 0, 0.5 );
box-shadow: 0 30px 90px -20px rgba( 0, 0, 0, 0.3 ), 0px 0px 1px rgba( 0, 0, 0, 0.5 );
padding: 0;
display: none;
font-size: 14px;
line-height: 20px;
min-width: 300px;
border-radius: 2px;
> div {
display: block;
.mwe-popups-container {
margin-top: -9px;
padding-top: 9px;
color: #000;
text-decoration: none;
> div {
padding: 0;
margin: 16px;
padding-bottom: 48px;
}
div.mwe-popups-is-not-tall,
div.mwe-popups-is-tall {
margin: 0;
height: 250px;
width: 200px;
padding: 0;
background-size: cover;
background-repeat: no-repeat;
overflow: hidden;
/* @noflip */
float: right;
}
div.mwe-popups-is-not-tall {
height: 200px;
width: 300px;
}
> footer {
footer {
padding: 16px;
margin: 0;
font-size: 10px;
@ -114,12 +101,8 @@
/* @noflip */
left: 0;
> div.mwe-popups-timestamp-older {
color: #555;
}
> div.mwe-popups-timestamp-recent {
color: #00af89;
.mwe-popups-icon {
float: right;
}
}
}
@ -130,10 +113,32 @@
display: block;
color: #000;
text-decoration: none;
position: relative;
&:hover {
text-decoration: none;
}
&:after {
content: ' ';
position: absolute;
bottom: 0;
width: 25%;
height: 24px;
background-color: transparent;
background-image: -webkit-linear-gradient( to right, rgba( 255, 255, 255, 0 ), rgba( 255, 255, 255, 1 ) 50% );
background-image: -moz-linear-gradient( to right, rgba( 255, 255, 255, 0 ), rgba( 255, 255, 255, 1 ) 50% );
background-image: -o-linear-gradient( to right, rgba( 255, 255, 255, 0 ), rgba( 255, 255, 255, 1 ) 50% );
background-image: linear-gradient( to right, rgba( 255, 255, 255, 0 ), rgba( 255, 255, 255, 1 ) 50% );
}
&[dir='ltr']:after {
right: 0;
}
&[dir='rtl']:after {
left: 0;
}
}
&.mwe-popups-is-tall {
@ -171,14 +176,32 @@
}
}
&.mwe-popups-is-empty {
.mwe-popups-extract {
padding-top: 4px;
margin-bottom: 60px;
}
.mwe-popups-read-link {
font-weight: bold;
font-size: 12px;
}
// When the user dwells on the "Looks like there isn't..." text, which is a
// link to the page, then highlight the "Read" link too.
.mwe-popups-extract:hover + footer .mwe-popups-read-link {
text-decoration: underline;
}
}
/* Triangles/Pokeys */
&.mwe-popups-no-image-tri {
&:after {
.mwe-popups-border-triangle-top( 7px, 7px, #fff, 4px);
.mwe-popups-border-triangle-top( 7px, 7px, #fff, 4px );
}
&:before {
.mwe-popups-border-triangle-top( 8px, 10px, #bbb, 0px);
.mwe-popups-border-triangle-top( 8px, 10px, #bbb, 0px );
}
}
@ -201,22 +224,22 @@
&.mwe-popups-image-tri {
&:before {
z-index: 111;
.mwe-popups-border-triangle-top(9px, 9px, #bbb, 0px);
.mwe-popups-border-triangle-top( 9px, 9px, #bbb, 0px );
}
&:after {
.mwe-popups-border-triangle-top(8px, 6px, #fff, 4px);
.mwe-popups-border-triangle-top( 8px, 6px, #fff, 4px );
z-index: 112;
}
&.flipped_x {
&:before {
z-index: 111;
.mwe-popups-border-triangle-top(9px, 273px, #bbb, 0px);
.mwe-popups-border-triangle-top( 9px, 273px, #bbb, 0px );
}
&:after {
.mwe-popups-border-triangle-top(8px, 269px, #fff, 4px);
.mwe-popups-border-triangle-top( 8px, 269px, #fff, 4px );
z-index: 112;
}
}
@ -240,7 +263,7 @@
&:before {
z-index: 111;
.mwe-popups-border-triangle-top(9px, 420px, #bbb, 0px);
.mwe-popups-border-triangle-top( 9px, 420px, #bbb, 0px );
}
> div > a > svg {
@ -257,11 +280,11 @@
&.flipped_x_y {
&:before {
z-index: 111;
.mwe-popups-border-triangle-bottom(9px, 272px, #bbb, 0px);
.mwe-popups-border-triangle-bottom( 9px, 272px, #bbb, 0px );
}
&:after {
.mwe-popups-border-triangle-bottom(8px, 269px, #fff, 4px);
.mwe-popups-border-triangle-bottom( 8px, 269px, #fff, 4px );
z-index: 112;
}
@ -270,12 +293,12 @@
&:after {
z-index: 112;
.mwe-popups-border-triangle-bottom(8px, 417px, #fff, 4px);
.mwe-popups-border-triangle-bottom( 8px, 417px, #fff, 4px );
}
&:before {
z-index: 111;
.mwe-popups-border-triangle-bottom(9px, 420px, #bbb, 0px);
.mwe-popups-border-triangle-bottom( 9px, 420px, #bbb, 0px );
}
> div > a > svg {

View file

@ -1,4 +1,5 @@
@import "mediawiki.mixins.less";
@import 'mediawiki.mixins.less';
@import 'mediawiki.ui/variables';
@dialog-margin: 50px;
@ -8,7 +9,7 @@
background: #fff;
width: 450px;
border: 1px solid #ccc;
box-shadow: 0px 1px 1px rgba(0,0,0,0.1);
box-shadow: 0px 1px 1px rgba( 0, 0, 0, 0.1 );
border-radius: 2px;
header {
@ -21,7 +22,7 @@
> div {
display: table-cell;
width: 3.35em;
width: @iconSize + ( 2 * @iconGutterWidth );
vertical-align: middle;
cursor: pointer;
}
@ -83,7 +84,7 @@
}
}
#mwe-popups-settings-help {
.mwe-popups-settings-help {
font-size: 18px;
font-weight: 800;
margin: @dialog-margin;
@ -101,14 +102,14 @@
background: #eee;
height: 65px;
width: 450px;
.background-image-svg( "images/footer-ltr.svg", "images/footer-ltr.png" );
.background-image-svg( '../images/footer-ltr.svg', '../images/footer-ltr.png' );
background-position: center;
background-repeat: no-repeat;
}
}
.rtl #mwe-popups-settings-help div.mwe-popups-settings-help-image {
.background-image-svg( "images/footer-rtl.svg", "images/footer-rtl.png" );
.rtl .mwe-popups-settings-help div.mwe-popups-settings-help-image {
.background-image-svg( '../images/footer-rtl.svg', '../images/footer-rtl.png' );
}
.mwe-popups-overlay {

View file

@ -0,0 +1,13 @@
<div class="mwe-popups mwe-popups-is-empty" role="tooltip" aria-hidden>
<div class="mwe-popups-container">
<div class="mwe-popups-sade-face-icon"></div>
<a href="{{url}}" class="mwe-popups-extract">{{extractMsg}}</a>
<footer>
{{! If the preview "is empty", i.e. a preview can't be generated, then we
show a link to the page to make it clear that the preview can be
clicked. }}
<a href="{{url}}" class="mwe-popups-read-link">{{readMsg}}</a>
</footer>
</div>
</div>

View file

@ -0,0 +1,12 @@
{{! Extracted from `mw.popups.render.createPopupElement` }}
<div class="mwe-popups" role="tooltip" aria-hidden>
<div class="mwe-popups-container">
{{#hasThumbnail}}
<a href="{{url}}" class="mwe-popups-discreet"></a>
{{/hasThumbnail}}
<a dir="{{languageDirection}}" lang="{{languageCode}}" class="mwe-popups-extract" href="{{url}}"></a>
<footer>
<a class="mwe-popups-icon mwe-popups-settings-icon"></a>
</footer>
</div>
</div>

View file

@ -22,7 +22,7 @@
{{/choices}}
</form>
</main>
<div id="mwe-popups-settings-help" style="display:none;">
<div class="mwe-popups-settings-help" style="display:none;">
<p>{{helpText}}</p>
<div class="mwe-popups-settings-help-image"></div>
</div>

18
src/actionTypes.js Normal file
View file

@ -0,0 +1,18 @@
module.exports = {
BOOT: 'BOOT',
CHECKIN: 'CHECKIN',
LINK_DWELL: 'LINK_DWELL',
ABANDON_START: 'ABANDON_START',
ABANDON_END: 'ABANDON_END',
LINK_CLICK: 'LINK_CLICK',
FETCH_START: 'FETCH_START',
FETCH_END: 'FETCH_END',
FETCH_FAILED: 'FETCH_FAILED',
PREVIEW_DWELL: 'PREVIEW_DWELL',
PREVIEW_SHOW: 'PREVIEW_SHOW',
PREVIEW_CLICK: 'PREVIEW_CLICK',
SETTINGS_SHOW: 'SETTINGS_SHOW',
SETTINGS_HIDE: 'SETTINGS_HIDE',
SETTINGS_CHANGE: 'SETTINGS_CHANGE',
EVENT_LOGGED: 'EVENT_LOGGED'
};

311
src/actions.js Normal file
View file

@ -0,0 +1,311 @@
( function ( mw, $ ) {
var actions = {},
types = require( './actionTypes' ),
FETCH_START_DELAY = 50, // ms.
// The delay after which a FETCH_END 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
// 300 - t to make the preview delay consistent to the user.
FETCH_END_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 `mw.popups.isEnabled`
* @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`
* @returns {Object}
*/
actions.boot = function (
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
}
};
};
/**
* How long has the user been actively reading the page?
* @param {number} time The number of seconds the user has seen the page
* @returns {{type: string, time: number}}
*/
actions.checkin = function ( time ) {
return {
type: types.CHECKIN,
time: time
};
};
/**
* Represents Page Previews fetching data via the gateway.
*
* @param {ext.popups.Gateway} gateway
* @param {Element} el
* @param {Date} started The time at which the interaction started.
* @return {Redux.Thunk}
*/
actions.fetch = function ( gateway, el, started ) {
var title = $( el ).data( 'page-previews-title' );
return function ( dispatch ) {
dispatch( {
type: types.FETCH_START,
el: el,
title: title
} );
gateway.getPageSummary( title )
.fail( function () {
dispatch( {
type: types.FETCH_FAILED,
el: el
} );
} )
.done( function ( result ) {
var now = mw.now(),
delay;
// If the API request has taken longer than the target delay, then
// don't delay any further.
delay = Math.max(
FETCH_END_TARGET_DELAY - Math.round( now - started ),
0
);
mw.popups.wait( delay )
.then( function () {
dispatch( {
type: types.FETCH_END,
el: el,
result: result
} );
} );
} );
};
};
/**
* 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 {Element} el
* @param {Event} event
* @param {ext.popups.Gateway} gateway
* @param {Function} generateToken
* @return {Redux.Thunk}
*/
actions.linkDwell = function ( el, event, gateway, generateToken ) {
var token = generateToken();
return function ( dispatch, getState ) {
var action = timedAction( {
type: types.LINK_DWELL,
el: el,
event: event,
token: token
} );
// Has the new generated token been accepted?
function isNewInteraction() {
return getState().preview.activeToken === token;
}
dispatch( action );
if ( !isNewInteraction() ) {
return;
}
mw.popups.wait( FETCH_START_DELAY )
.then( function () {
var previewState = getState().preview;
if ( previewState.enabled && isNewInteraction() ) {
dispatch( actions.fetch( gateway, el, action.timestamp ) );
}
} );
};
};
/**
* 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}
*/
actions.abandon = function () {
return function ( dispatch, getState ) {
var token = getState().preview.activeToken;
dispatch( timedAction( {
type: types.ABANDON_START,
token: token
} ) );
mw.popups.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}
*/
actions.linkClick = function ( el ) {
return timedAction( {
type: types.LINK_CLICK,
el: el
} );
};
/**
* Represents the user dwelling on a preview with their mouse.
*
* @return {Object}
*/
actions.previewDwell = function () {
return {
type: types.PREVIEW_DWELL
};
};
/**
* Represents a preview being shown to the user.
*
* This action is dispatched by the `mw.popups.changeListeners.render` change
* listener.
*
* @return {Object}
*/
actions.previewShow = function () {
return timedAction( {
type: types.PREVIEW_SHOW
} );
};
/**
* Represents the user clicking either the "Enable previews" footer menu link,
* or the "cog" icon that's present on each preview.
*
* @return {Object}
*/
actions.showSettings = function () {
return {
type: types.SETTINGS_SHOW
};
};
/**
* Represents the user closing the settings dialog and saving their settings.
*
* @return {Object}
*/
actions.hideSettings = function () {
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}
*/
actions.saveSettings = function ( enabled ) {
return function ( dispatch, getState ) {
dispatch( {
type: types.SETTINGS_CHANGE,
wasEnabled: getState().preview.enabled,
enabled: enabled
} );
};
};
/**
* Represents the queued event being logged
* `mw.popups.changeListeners.eventLogging` change listener.
*
* @return {Object}
*/
actions.eventLogged = function () {
return {
type: types.EVENT_LOGGED
};
};
module.exports = actions;
}( mediaWiki, jQuery ) );

38
src/changeListener.js Normal file
View file

@ -0,0 +1,38 @@
/**
* @typedef {Function} ext.popups.ChangeListener
* @param {Object} prevState The previous state
* @param {Object} state The current state
*/
/**
* Registers a change listener, which is bound to the
* [store](http://redux.js.org/docs/api/Store.html).
*
* A change listener is a function that is only invoked when the state in the
* [store](http://redux.js.org/docs/api/Store.html) changes. N.B. that there
* may not be a 1:1 correspondence with actions being dispatched to the store
* and the state in the store changing.
*
* See [Store#subscribe](http://redux.js.org/docs/api/Store.html#subscribe)
* for more information about what change listeners may and may not do.
*
* @param {Redux.Store} store
* @param {ext.popups.ChangeListener} callback
*/
module.exports = function ( store, callback ) {
// This function is based on the example in [the documentation for
// Store#subscribe](http://redux.js.org/docs/api/Store.html#subscribe),
// which was written by Dan Abramov.
var state;
store.subscribe( function () {
var prevState = state;
state = store.getState();
if ( prevState !== state ) {
callback( prevState, state );
}
} );
};

View file

@ -0,0 +1,28 @@
( function ( $ ) {
/**
* Creates an instance of the event logging change listener.
*
* When an event is enqueued to be logged it'll be logged using the schema.
* Since it's the responsibility of EventLogging (and the UA) to deliver
* logged events, the `EVENT_LOGGED` is immediately dispatched rather than
* waiting for some indicator of completion.
*
* @param {Object} boundActions
* @param {mw.eventLog.Schema} schema
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( boundActions, schema ) {
return function ( _, state ) {
var eventLogging = state.eventLogging,
event = eventLogging.event;
if ( event ) {
schema.log( $.extend( true, {}, eventLogging.baseData, event ) );
boundActions.eventLogged();
}
};
};
}( jQuery ) );

View file

@ -0,0 +1,74 @@
( function ( mw, $ ) {
/**
* Creates the link element and appends it to the footer element.
*
* The following elements are considered to be the footer element (highest
* priority to lowest):
*
* # `#footer-places`
* # `#f-list`
* # The parent element of `#footer li`, which is either an `ol` or `ul`.
*
* @return {jQuery} The link element
*/
function createFooterLink() {
var $link = $( '<li>' ).append(
$( '<a>' )
.attr( 'href', '#' )
.text( mw.message( 'popups-settings-enable' ).text() )
),
$footer;
// As yet, we don't know whether the link should be visible.
$link.hide();
// From https://en.wikipedia.org/wiki/MediaWiki:Gadget-ReferenceTooltips.js,
// which was written by Yair rand <https://en.wikipedia.org/wiki/User:Yair_rand>.
$footer = $( '#footer-places, #f-list' );
if ( $footer.length === 0 ) {
$footer = $( '#footer li' ).parent();
}
$footer.append( $link );
return $link;
}
/**
* Creates an instance of the footer link change listener.
*
* The change listener covers the following behaviour:
*
* * The "Enable previews" link (the "link") is appended to the footer menu
* (see `createFooterLink` above).
* * When Page Previews are disabled, then the link is shown; otherwise, the
* link is hidden.
* * When the user clicks the link, then the `showSettings` bound action
* creator is called.
*
* @param {Object} boundActions
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( boundActions ) {
var $footerLink;
return function ( prevState, state ) {
if ( $footerLink === undefined ) {
$footerLink = createFooterLink();
$footerLink.click( function ( e ) {
e.preventDefault();
boundActions.showSettings();
} );
}
if ( state.settings.shouldShowFooterLink ) {
$footerLink.show();
} else {
$footerLink.hide();
}
};
};
}( mediaWiki, jQuery ) );

View file

@ -0,0 +1,8 @@
module.exports = {
footerLink: require( './footerLink' ),
eventLogging: require( './eventLogging' ),
linkTitle: require( './linkTitle' ),
render: require( './render' ),
settings: require( './settings' ),
syncUserSettings: require( './syncUserSettings' )
};

View file

@ -0,0 +1,65 @@
( function ( $ ) {
/**
* Creates an instance of the link title change listener.
*
* While the user dwells on a link, then it becomes the active link. The
* change listener will remove a link's `title` attribute while it's the
* active link.
*
* @return {ext.popups.ChangeListener}
*/
module.exports = function () {
var title;
/**
* Destroys the title attribute of the element, storing its value in local
* state so that it can be restored later (see `restoreTitleAttr`).
*
* @param {Element} el
*/
function destroyTitleAttr( el ) {
var $el = $( el );
// Has the user dwelled on a link? If we've already removed its title
// attribute, then NOOP.
if ( title ) {
return;
}
title = $el.attr( 'title' );
$el.attr( 'title', '' );
}
/**
* Restores the title attribute of the element.
*
* @param {Element} el
*/
function restoreTitleAttr( el ) {
$( el ).attr( 'title', title );
title = undefined;
}
return function ( prevState, state ) {
var hasPrevActiveLink = prevState && prevState.preview.activeLink;
if ( hasPrevActiveLink ) {
// Has the user dwelled on a link immediately after abandoning another
// (remembering that the ABANDON_END action is delayed by
// ~10e2 ms).
if ( prevState.preview.activeLink !== state.preview.activeLink ) {
restoreTitleAttr( prevState.preview.activeLink );
}
}
if ( state.preview.activeLink ) {
destroyTitleAttr( state.preview.activeLink );
}
};
};
}( jQuery ) );

View file

@ -0,0 +1,23 @@
( function ( mw ) {
/**
* Creates an instance of the render change listener.
*
* @param {ext.popups.PreviewBehavior} previewBehavior
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( previewBehavior ) {
var preview;
return function ( prevState, state ) {
if ( state.preview.shouldShow && !preview ) {
preview = mw.popups.renderer.render( state.preview.fetchResponse );
preview.show( state.preview.activeEvent, previewBehavior );
} else if ( !state.preview.shouldShow && preview ) {
preview.hide();
preview = undefined;
}
};
};
}( mediaWiki ) );

View file

@ -0,0 +1,44 @@
/**
* Creates an instance of the settings change listener.
*
* @param {Object} boundActions
* @param {Object} render function that renders a jQuery el with the settings
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( boundActions, render ) {
var settings;
return function ( prevState, state ) {
if ( !prevState ) {
// Nothing to do on initialization
return;
}
// Update global modal visibility
if (
prevState.settings.shouldShow === false &&
state.settings.shouldShow === true
) {
// Lazily instantiate the settings UI
if ( !settings ) {
settings = render( boundActions );
settings.appendTo( document.body );
}
// Update the UI settings with the current settings
settings.setEnabled( state.preview.enabled );
settings.show();
} else if (
prevState.settings.shouldShow === true &&
state.settings.shouldShow === false
) {
settings.hide();
}
// Update help visibility
if ( prevState.settings.showHelp !== state.settings.showHelp ) {
settings.toggleHelp( state.settings.showHelp );
}
};
};

View file

@ -0,0 +1,60 @@
/**
* Creates an instance of the user settings sync change listener.
*
* This change listener syncs certain parts of the state tree to user
* settings when they change.
*
* Used for:
*
* * Enabled state: If the previews are enabled or disabled.
* * Preview count: When the user dwells on a link for long enough that
* a preview is shown, then their preview count will be incremented (see
* `mw.popups.reducers.eventLogging`, and is persisted to local storage.
*
* @param {ext.popups.UserSettings} userSettings
* @return {ext.popups.ChangeListener}
*/
module.exports = function ( userSettings ) {
return function ( prevState, state ) {
syncIfChanged(
prevState, state, 'eventLogging', 'previewCount',
userSettings.setPreviewCount
);
syncIfChanged(
prevState, state, 'preview', 'enabled',
userSettings.setIsEnabled
);
};
};
/**
* Given a state tree, reducer and property, safely return the value of the
* property if the reducer and property exist
* @param {Object} state tree
* @param {String} reducer key to access on the state tree
* @param {String} prop key to access on the reducer key of the state tree
* @return {*}
*/
function get( state, reducer, prop ) {
return state[ reducer ] && state[ reducer ][ prop ];
}
/**
* Calls a sync function if the property prop on the property reducer on
* the state trees has changed value.
* @param {Object} prevState
* @param {Object} state
* @param {String} reducer key to access on the state tree
* @param {String} prop key to access on the reducer key of the state tree
* @param {Function} sync function to be called with the newest value if
* changed
*/
function syncIfChanged( prevState, state, reducer, prop, sync ) {
var current = get( state, reducer, prop );
if ( prevState && ( get( prevState, reducer, prop ) !== current ) ) {
sync( current );
}
}

130
src/checkin.js Normal file
View file

@ -0,0 +1,130 @@
( function ( mw, $ ) {
var pageVisibility = require( './pageVisibility' ),
checkin = {
/**
* Checkin times - Fibonacci numbers
*
* Exposed for testing only.
*
* @type {number[]}
* @private
*/
CHECKIN_TIMES: [ 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,
377, 610, 987, 1597, 2584, 4181, 6765 ],
/**
* Have checkin actions been setup already?
*
* Exposed for testing only.
*
* @private
* @type {boolean}
*/
haveCheckinActionsBeenSetup: false
};
/**
* A customized `setTimeout` function that takes page visibility into account
*
* If the document is not visible to the user, e.g. browser window is minimized,
* then pause the time. Otherwise execute `callback` after `delay` milliseconds.
* The callback won't be executed if the browser does not suppor the page visibility
* API.
*
* Exposed for testing only.
*
* @see https://www.w3.org/TR/page-visibility/#dom-document-hidden
* @private
* @param {Function} callback Function to call when the time is up
* @param {number} delay The number of milliseconds to wait before executing the callback
*/
checkin.setVisibleTimeout = function ( callback, delay ) {
var hiddenPropertyName = pageVisibility.getDocumentHiddenPropertyName( document ),
visibilityChangeEventName = pageVisibility.getDocumentVisibilitychangeEventName( document ),
timeoutId,
lastStartedAt;
if ( !hiddenPropertyName || !visibilityChangeEventName ) {
return;
}
/**
* Execute the callback and turn off listening to the visibilitychange event
*/
function done() {
callback();
$( document ).off( visibilityChangeEventName, visibilityChangeHandler );
}
/**
* Pause or resume the timer depending on the page visibility state
*/
function visibilityChangeHandler() {
var millisecondsPassed;
// Pause the timer if the page is hidden ...
if ( pageVisibility.isDocumentHidden( document ) ) {
// ... and only if the timer has started.
// Timer may not have been started if the document opened in a
// hidden tab for example. The timer will be started when the
// document is visible to the user.
if ( lastStartedAt !== undefined ) {
millisecondsPassed = Math.round( mw.now() - lastStartedAt );
delay = Math.max( 0, delay - millisecondsPassed );
clearTimeout( timeoutId );
}
} else {
lastStartedAt = Math.round( mw.now() );
timeoutId = setTimeout( done, delay );
}
}
visibilityChangeHandler();
$( document ).on( visibilityChangeEventName, visibilityChangeHandler );
};
/**
* Perform the passed `checkin` action at the predefined times
*
* Actions are setup only once no matter how many times this function is
* called. Ideally this function should be called once.
*
* @see checkin.CHECKIN_TIMES
* @param {Function} checkinAction
*/
checkin.setupActions = function( checkinAction ) {
var timeIndex = 0,
timesLength = checkin.CHECKIN_TIMES.length,
time, // current checkin time
nextTime; // the checkin time that will be logged next
if ( checkin.haveCheckinActionsBeenSetup ) {
return;
}
/**
* Execute the checkin action with the current checkin time
*
* If more checkin times are left, then setup a timer to log the next one.
*/
function setup() {
time = checkin.CHECKIN_TIMES[ timeIndex ];
checkinAction( time );
timeIndex += 1;
if ( timeIndex < timesLength ) {
nextTime = checkin.CHECKIN_TIMES[ timeIndex ];
// Execute the callback after the number of seconds left till the
// next checkin time.
checkin.setVisibleTimeout( setup, ( nextTime - time ) * 1000 );
}
}
checkin.setVisibleTimeout( setup, checkin.CHECKIN_TIMES[ timeIndex ] * 1000 );
checkin.haveCheckinActionsBeenSetup = true;
};
module.exports = checkin;
}( mediaWiki, jQuery ) );

67
src/counts.js Normal file
View file

@ -0,0 +1,67 @@
/**
* Return count bucket for the number of edits a user has made.
*
* The buckets are defined as part of
* [the Popups schema](https://meta.wikimedia.org/wiki/Schema:Popups).
*
* Extracted from `mw.popups.schemaPopups.getEditCountBucket`.
*
* @param {Number} count
* @return {String}
*/
function getEditCountBucket( count ) {
var bucket;
if ( count === 0 ) {
bucket = '0';
} else if ( count >= 1 && count <= 4 ) {
bucket = '1-4';
} else if ( count >= 5 && count <= 99 ) {
bucket = '5-99';
} else if ( count >= 100 && count <= 999 ) {
bucket = '100-999';
} else if ( count >= 1000 ) {
bucket = '1000+';
}
return bucket + ' edits';
}
/**
* Return count bucket for the number of previews a user has seen.
*
* If local storage isn't available - because the user has disabled it
* or the browser doesn't support it - then then "unknown" is returned.
*
* The buckets are defined as part of
* [the Popups schema](https://meta.wikimedia.org/wiki/Schema:Popups).
*
* Extracted from `mw.popups.getPreviewCountBucket`.
*
* @param {Number} count
* @return {String}
*/
function getPreviewCountBucket( count ) {
var bucket;
if ( count === -1 ) {
return 'unknown';
}
if ( count === 0 ) {
bucket = '0';
} else if ( count >= 1 && count <= 4 ) {
bucket = '1-4';
} else if ( count >= 5 && count <= 20 ) {
bucket = '5-20';
} else if ( count >= 21 ) {
bucket = '21+';
}
return bucket + ' previews';
}
module.exports = {
getPreviewCountBucket: getPreviewCountBucket,
getEditCountBucket: getEditCountBucket
};

19
src/gateway/index.js Normal file
View file

@ -0,0 +1,19 @@
/**
* Interface for API gateway that fetches page summary
*
* @interface ext.popups.Gateway
*/
/**
* Returns a preview model fetched from the api
* @function
* @name ext.popups.Gateway#getPageSummary
* @param {String} title Page title we're querying
* @returns {jQuery.Promise} that resolves with {ext.popups.PreviewModel}
* if the request is successful and the response is not empty; otherwise
* it rejects.
*/
module.exports = {
createMediaWikiApiGateway: require( './mediawiki' ),
createRESTBaseGateway: require( './rest' )
};

109
src/gateway/mediawiki.js Normal file
View file

@ -0,0 +1,109 @@
( function ( mw, $ ) {
var EXTRACT_LENGTH = 525,
THUMBNAIL_SIZE = 300 * $.bracketedDevicePixelRatio(),
// Public and private cache lifetime (5 minutes)
CACHE_LIFETIME = 300;
/**
* MediaWiki API gateway factory
*
* @param {mw.Api} api
* @returns {ext.popups.Gateway}
*/
function createMediaWikiApiGateway( api ) {
/**
* Fetch page data from the API
*
* @param {String} title
* @return {jQuery.Promise}
*/
function fetch( title ) {
return api.get( {
action: 'query',
prop: 'info|extracts|pageimages|revisions|info',
formatversion: 2,
redirects: true,
exintro: true,
exchars: EXTRACT_LENGTH,
// There is an added geometric limit on .mwe-popups-extract
// so that text does not overflow from the card.
explaintext: true,
piprop: 'thumbnail',
pithumbsize: THUMBNAIL_SIZE,
rvprop: 'timestamp',
inprop: 'url',
titles: title,
smaxage: CACHE_LIFETIME,
maxage: CACHE_LIFETIME,
uselang: 'content'
}, {
headers: {
'X-Analytics': 'preview=1'
}
} );
}
/**
* Get the page summary from the api and transform the data
*
* @param {String} title
* @returns {jQuery.Promise<ext.popups.PreviewModel>}
*/
function getPageSummary( title ) {
return fetch( title )
.then( extractPageFromResponse )
.then( convertPageToModel );
}
return {
fetch: fetch,
extractPageFromResponse: extractPageFromResponse,
convertPageToModel: convertPageToModel,
getPageSummary: getPageSummary
};
}
/**
* Extract page data from the MediaWiki API response
*
* @param {Object} data API response data
* @throws {Error} Throw an error if page data cannot be extracted,
* i.e. if the response is empty,
* @returns {Object}
*/
function extractPageFromResponse( data ) {
if (
data.query &&
data.query.pages &&
data.query.pages.length
) {
return data.query.pages[ 0 ];
}
throw new Error( 'API response `query.pages` is empty.' );
}
/**
* Transform the MediaWiki API response to a preview model
*
* @param {Object} page
* @returns {ext.popups.PreviewModel}
*/
function convertPageToModel( page ) {
return mw.popups.preview.createModel(
page.title,
page.canonicalurl,
page.pagelanguagehtmlcode,
page.pagelanguagedir,
page.extract,
page.thumbnail
);
}
module.exports = createMediaWikiApiGateway;
}( mediaWiki, jQuery ) );

67
src/gateway/rest.js Normal file
View file

@ -0,0 +1,67 @@
( function ( mw ) {
var RESTBASE_ENDPOINT = '/api/rest_v1/page/summary/',
RESTBASE_PROFILE = 'https://www.mediawiki.org/wiki/Specs/Summary/1.0.0';
/**
* RESTBase gateway factory
*
* @param {Function} ajax function from jQuery for example
* @returns {ext.popups.Gateway}
*/
function createRESTBaseGateway( ajax ) {
/**
* Fetch page data from the API
*
* @param {String} title
* @return {jQuery.Promise}
*/
function fetch( title ) {
return ajax( {
url: RESTBASE_ENDPOINT + encodeURIComponent( title ),
headers: {
Accept: 'application/json; charset=utf-8' +
'profile="' + RESTBASE_PROFILE + '"'
}
} );
}
/**
* Get the page summary from the api and transform the data
*
* @param {String} title
* @returns {jQuery.Promise<ext.popups.PreviewModel>}
*/
function getPageSummary( title ) {
return fetch( title )
.then( convertPageToModel );
}
return {
fetch: fetch,
convertPageToModel: convertPageToModel,
getPageSummary: getPageSummary
};
}
/**
* Transform the rest API response to a preview model
*
* @param {Object} page
* @returns {ext.popups.PreviewModel}
*/
function convertPageToModel( page ) {
return mw.popups.preview.createModel(
page.title,
new mw.Title( page.title ).getUrl(),
page.lang,
page.dir,
page.extract,
page.thumbnail
);
}
module.exports = createRESTBaseGateway;
}( mediaWiki ) );

160
src/index.js Normal file
View file

@ -0,0 +1,160 @@
( function ( mw, popups, Redux, ReduxThunk, $ ) {
var BLACKLISTED_LINKS = [
'.extiw',
'.image',
'.new',
'.internal',
'.external',
'.oo-ui-buttonedElement-button',
'.cancelLink a'
];
/**
* Creates a gateway with sensible values for the dependencies.
*
* @param {mw.Map} config
* @return {ext.popups.Gateway}
*/
function createGateway( config ) {
if ( config.get( 'wgPopupsAPIUseRESTBase' ) ) {
return popups.gateway.createRESTBaseGateway( $.ajax );
}
return popups.gateway.createMediaWikiApiGateway( new mw.Api() );
}
/**
* Subscribes the registered change listeners to the
* [store](http://redux.js.org/docs/api/Store.html#store).
*
* Change listeners are registered by setting a property on
* `popups.changeListeners`.
*
* @param {Redux.Store} store
* @param {Object} actions
* @param {mw.eventLog.Schema} schema
* @param {ext.popups.UserSettings} userSettings
* @param {Function} settingsDialog
* @param {ext.popups.PreviewBehavior} previewBehavior
*/
function registerChangeListeners( store, actions, schema, userSettings, settingsDialog, previewBehavior ) {
// Sugar.
var changeListeners = popups.changeListeners,
registerChangeListener = popups.registerChangeListener;
registerChangeListener( store, changeListeners.footerLink( actions ) );
registerChangeListener( store, changeListeners.linkTitle() );
registerChangeListener( store, changeListeners.render( previewBehavior ) );
registerChangeListener( store, changeListeners.eventLogging( actions, schema ) );
registerChangeListener( store, changeListeners.syncUserSettings( userSettings ) );
registerChangeListener( store, changeListeners.settings( actions, settingsDialog ) );
}
/**
* Binds the actions (or "action creators") to the
* [store](http://redux.js.org/docs/api/Store.html#store).
*
* @param {Redux.Store} store
* @return {Object}
*/
function createBoundActions( store ) {
return Redux.bindActionCreators( popups.actions, store.dispatch );
}
/**
* Creates the reducer for all actions.
*
* @return {Redux.Reducer}
*/
function createRootReducer() {
return Redux.combineReducers( popups.reducers );
}
/*
* Initialize the application by:
* 1. Creating the state store
* 2. Binding the actions to such store
* 3. Trigger the boot action to bootstrap the system
* 4. When the page content is ready:
* - Setup `checkin` actions
* - Process the eligible links for page previews
* - Initialize the renderer
* - Bind hover and click events to the eligible links to trigger actions
*/
mw.requestIdleCallback( function () {
var compose = Redux.compose,
store,
actions,
// So-called "services".
generateToken = mw.user.generateRandomSessionId,
gateway = createGateway( mw.config ),
userSettings,
settingsDialog,
isEnabled,
schema,
previewBehavior;
userSettings = popups.createUserSettings( mw.storage );
settingsDialog = popups.createSettingsDialogRenderer();
schema = popups.createSchema( mw.config, window );
isEnabled = popups.isEnabled( mw.user, userSettings, mw.config );
// If debug mode is enabled, then enable Redux DevTools.
if ( mw.config.get( 'debug' ) === true ) {
// eslint-disable-next-line no-underscore-dangle
compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
}
store = Redux.createStore(
createRootReducer(),
compose( Redux.applyMiddleware(
ReduxThunk.default
) )
);
actions = createBoundActions( store );
previewBehavior = popups.createPreviewBehavior( mw.config, mw.user, actions );
registerChangeListeners( store, actions, schema, userSettings, settingsDialog, previewBehavior );
actions.boot(
isEnabled,
mw.user,
userSettings,
generateToken,
mw.config
);
mw.hook( 'wikipage.content' ).add( function ( $container ) {
var previewLinks =
popups.processLinks(
$container,
BLACKLISTED_LINKS,
mw.config
);
popups.checkin.setupActions( actions.checkin );
popups.renderer.init();
previewLinks
.on( 'mouseover focus', function ( event ) {
actions.linkDwell( this, event, gateway, generateToken );
} )
.on( 'mouseout blur', function () {
actions.abandon( this );
} )
.on( 'click', function () {
actions.linkClick( this );
} );
} );
} );
// FIXME: Currently needs to be exposed for testing purposes
mw.popups = popups;
window.Redux = Redux;
window.ReduxThunk = ReduxThunk;
}( mediaWiki, require( './popups' ), require( 'redux' ), require( 'redux-thunk' ), jQuery ) );

31
src/isEnabled.js Normal file
View file

@ -0,0 +1,31 @@
/**
* Given the global state of the application, creates a function that gets
* whether or not the user should have Page Previews enabled.
*
* If Page Previews is configured as a beta feature (see
* `$wgPopupsBetaFeature`), the user must be logged in and have enabled the
* beta feature in order to see previews.
*
* If Page Previews is configured as a preference, then the user must either
* be logged in and have enabled the preference or be logged out and have not
* disabled previews via the settings modal.
*
* @param {mw.user} user The `mw.user` singleton instance
* @param {Object} userSettings An object returned by
* `mw.popups.createUserSettings`
* @param {mw.Map} config
*
* @return {Boolean}
*/
module.exports = function ( user, userSettings, config ) {
if ( !user.isAnon() ) {
return config.get( 'wgPopupsShouldSendModuleToUser' );
}
if ( config.get( 'wgPopupsBetaFeature' ) ) {
return false;
}
return !userSettings.hasIsEnabled() ||
( userSettings.hasIsEnabled() && userSettings.getIsEnabled() );
};

109
src/pageVisibility.js Normal file
View file

@ -0,0 +1,109 @@
var pageVisibility = {
/**
* Cached value of the browser specific name of the `document.hidden` property
*
* Exposed for testing only.
*
* @type {null|undefined|string}
* @private
*/
documentHiddenPropertyName: null,
/**
* Cached value of the browser specific name of the `document.visibilitychange` event
*
* Exposed for testing only.
*
* @type {null|undefined|string}
* @private
*/
documentVisibilityChangeEventName: null
};
/**
* Return the browser specific name of the `document.hidden` property
*
* Exposed for testing only.
*
* @see https://www.w3.org/TR/page-visibility/#dom-document-hidden
* @see https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
* @private
* @param {Object} doc window.document object
* @return {string|undefined}
*/
pageVisibility.getDocumentHiddenPropertyName = function ( doc ) {
var property;
if ( pageVisibility.documentHiddenPropertyName === null ) {
if ( doc.hidden !== undefined ) {
property = 'hidden';
} else if ( doc.mozHidden !== undefined ) {
property = 'mozHidden';
} else if ( doc.msHidden !== undefined ) {
property = 'msHidden';
} else if ( doc.webkitHidden !== undefined ) {
property = 'webkitHidden';
} else {
// let's be explicit about returning `undefined`
property = undefined;
}
// cache
pageVisibility.documentHiddenPropertyName = property;
}
return pageVisibility.documentHiddenPropertyName;
};
/**
* Return the browser specific name of the `document.visibilitychange` event
*
* Exposed for testing only.
*
* @see https://www.w3.org/TR/page-visibility/#sec-visibilitychange-event
* @see https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
* @private
* @param {Object} doc window.document object
* @return {string|undefined}
*/
pageVisibility.getDocumentVisibilitychangeEventName = function ( doc ) {
var eventName;
if ( pageVisibility.documentVisibilityChangeEventName === null ) {
if ( doc.hidden !== undefined ) {
eventName = 'visibilitychange';
} else if ( doc.mozHidden !== undefined ) {
eventName = 'mozvisibilitychange';
} else if ( doc.msHidden !== undefined ) {
eventName = 'msvisibilitychange';
} else if ( doc.webkitHidden !== undefined ) {
eventName = 'webkitvisibilitychange';
} else {
// let's be explicit about returning `undefined`
eventName = undefined;
}
// cache
pageVisibility.documentVisibilityChangeEventName = eventName;
}
return pageVisibility.documentVisibilityChangeEventName;
};
/**
* Whether `window.document` is visible
*
* `undefined` is returned if the browser does not support the Visibility API.
*
* Exposed for testing only.
*
* @see https://www.w3.org/TR/page-visibility/#dom-document-hidden
* @see https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
* @private
* @param {Object} doc window.document object
* @returns {boolean|undefined}
*/
pageVisibility.isDocumentHidden = function ( doc ) {
var property = pageVisibility.getDocumentHiddenPropertyName( doc );
return property !== undefined ? doc[ property ] : undefined;
};
module.exports = pageVisibility;

20
src/popups.js Normal file
View file

@ -0,0 +1,20 @@
module.exports = {
actions: require( './actions' ),
actionTypes: require( './actionTypes' ),
changeListeners: require( './changeListeners' ),
checkin: require( './checkin' ),
counts: require( './counts' ),
createPreviewBehavior: require( './previewBehavior' ),
createUserSettings: require( './userSettings' ),
createSchema: require( './schema' ),
createSettingsDialogRenderer: require( './settingsDialog' ),
gateway: require( './gateway' ),
isEnabled: require( './isEnabled' ),
renderer: require( './renderer' ),
pageVisibility: require( './pageVisibility' ),
preview: require( './preview' ),
processLinks: require( './processLinks' ),
registerChangeListener: require( './changeListener' ),
reducers: require( './reducers' ),
wait: require( './wait' )
};

139
src/preview/index.js Normal file
View file

@ -0,0 +1,139 @@
var createModel,
TYPE_GENERIC = 'generic',
TYPE_PAGE = 'page';
/**
* @typedef {Object} ext.popups.PreviewModel
* @property {String} title
* @property {String} url The canonical URL of the page being previewed
* @property {String} languageCode
* @property {String} languageDirection Either "ltr" or "rtl"
* @property {String|undefined} extract `undefined` if the extract isn't
* viable, e.g. if it's empty after having ellipsis and parentheticals
* removed
* @property {String} type Either "EXTRACT" or "GENERIC"
* @property {Object|undefined} thumbnail
*/
/**
* Creates a preview model.
*
* @param {String} title
* @param {String} url The canonical URL of the page being previewed
* @param {String} languageCode
* @param {String} languageDirection Either "ltr" or "rtl"
* @param {String} extract
* @param {Object|undefined} thumbnail
* @return {ext.popups.PreviewModel}
*/
createModel = function (
title,
url,
languageCode,
languageDirection,
extract,
thumbnail
) {
var processedExtract = processExtract( extract ),
result = {
title: title,
url: url,
languageCode: languageCode,
languageDirection: languageDirection,
extract: processedExtract,
type: processedExtract === undefined ? TYPE_GENERIC : TYPE_PAGE,
thumbnail: thumbnail
};
return result;
};
/**
* Processes the extract returned by the TextExtracts MediaWiki API query
* module.
*
* @param {String|undefined} extract
* @return {String|undefined}
*/
function processExtract( extract ) {
var result;
if ( extract === undefined || extract === '' ) {
return undefined;
}
result = extract;
result = removeParentheticals( result );
result = removeEllipsis( result );
return result.length > 0 ? result : undefined;
}
/**
* Removes the trailing ellipsis from the extract, if it's there.
*
* This function was extracted from
* `mw.popups.renderer.article#removeEllipsis`.
*
* @param {String} extract
* @return {String}
*/
function removeEllipsis( extract ) {
return extract.replace( /\.\.\.$/, '' );
}
/**
* Removes parentheticals from the extract.
*
* If the parenthesis are unbalanced or out of order, then the extract is
* returned without further processing.
*
* This function was extracted from
* `mw.popups.renderer.article#removeParensFromText`.
*
* @param {String} extract
* @return {String}
*/
function removeParentheticals( extract ) {
var
ch,
result = '',
level = 0,
i = 0;
for ( i; i < extract.length; i++ ) {
ch = extract.charAt( i );
if ( ch === ')' && level === 0 ) {
return extract;
}
if ( ch === '(' ) {
level++;
continue;
} else if ( ch === ')' ) {
level--;
continue;
}
if ( level === 0 ) {
// Remove leading spaces before brackets
if ( ch === ' ' && extract.charAt( i + 1 ) === '(' ) {
continue;
}
result += ch;
}
}
return ( level === 0 ) ? result : extract;
}
module.exports = {
/**
* @constant {String}
*/
TYPE_GENERIC: TYPE_GENERIC,
/**
* @constant {String}
*/
TYPE_PAGE: TYPE_PAGE,
createModel: createModel
};

138
src/preview/model.js Normal file
View file

@ -0,0 +1,138 @@
var TYPE_GENERIC = 'generic',
TYPE_PAGE = 'page';
/**
* @typedef {Object} ext.popups.PreviewModel
* @property {String} title
* @property {String} url The canonical URL of the page being previewed
* @property {String} languageCode
* @property {String} languageDirection Either "ltr" or "rtl"
* @property {String|undefined} extract `undefined` if the extract isn't
* viable, e.g. if it's empty after having ellipsis and parentheticals
* removed
* @property {String} type Either "EXTRACT" or "GENERIC"
* @property {Object|undefined} thumbnail
*/
/**
* Creates a preview model.
*
* @param {String} title
* @param {String} url The canonical URL of the page being previewed
* @param {String} languageCode
* @param {String} languageDirection Either "ltr" or "rtl"
* @param {String} extract
* @param {Object|undefined} thumbnail
* @return {ext.popups.PreviewModel}
*/
function createModel(
title,
url,
languageCode,
languageDirection,
extract,
thumbnail
) {
var processedExtract = processExtract( extract ),
result = {
title: title,
url: url,
languageCode: languageCode,
languageDirection: languageDirection,
extract: processedExtract,
type: processedExtract === undefined ? TYPE_GENERIC : TYPE_PAGE,
thumbnail: thumbnail
};
return result;
}
/**
* Processes the extract returned by the TextExtracts MediaWiki API query
* module.
*
* @param {String|undefined} extract
* @return {String|undefined}
*/
function processExtract( extract ) {
var result;
if ( extract === undefined || extract === '' ) {
return undefined;
}
result = extract;
result = removeParentheticals( result );
result = removeEllipsis( result );
return result.length > 0 ? result : undefined;
}
/**
* Removes the trailing ellipsis from the extract, if it's there.
*
* This function was extracted from
* `mw.popups.renderer.article#removeEllipsis`.
*
* @param {String} extract
* @return {String}
*/
function removeEllipsis( extract ) {
return extract.replace( /\.\.\.$/, '' );
}
/**
* Removes parentheticals from the extract.
*
* If the parenthesis are unbalanced or out of order, then the extract is
* returned without further processing.
*
* This function was extracted from
* `mw.popups.renderer.article#removeParensFromText`.
*
* @param {String} extract
* @return {String}
*/
function removeParentheticals( extract ) {
var
ch,
result = '',
level = 0,
i = 0;
for ( i; i < extract.length; i++ ) {
ch = extract.charAt( i );
if ( ch === ')' && level === 0 ) {
return extract;
}
if ( ch === '(' ) {
level++;
continue;
} else if ( ch === ')' ) {
level--;
continue;
}
if ( level === 0 ) {
// Remove leading spaces before brackets
if ( ch === ' ' && extract.charAt( i + 1 ) === '(' ) {
continue;
}
result += ch;
}
}
return ( level === 0 ) ? result : extract;
}
module.exports = {
/**
* @constant {String}
*/
TYPE_GENERIC: TYPE_GENERIC,
/**
* @constant {String}
*/
TYPE_PAGE: TYPE_PAGE,
createModel: createModel
};

55
src/previewBehavior.js Normal file
View file

@ -0,0 +1,55 @@
( function ( mw, $ ) {
/**
* @typedef {Object} ext.popups.PreviewBehavior
* @property {String} settingsUrl
* @property {Function} showSettings
* @property {Function} previewDwell
* @property {Function} previewAbandon
*/
/**
* Creates an instance of `ext.popups.PreviewBehavior`.
*
* If the user is logged out, then clicking the cog should show the settings
* modal.
*
* If the user is logged in, then clicking the cog should send them to the
* Special:Preferences page with the "Beta features" tab open if Page Previews
* is enabled as a beta feature, or the "Appearance" tab otherwise.
*
* @param {mw.Map} config
* @param {mw.User} user
* @param {Object} actions The action creators bound to the Redux store
* @return {ext.popups.PreviewBehavior}
*/
module.exports = function ( config, user, actions ) {
var isBetaFeature = config.get( 'wgPopupsBetaFeature' ),
rawTitle,
settingsUrl,
showSettings = $.noop;
if ( user.isAnon() ) {
showSettings = function ( event ) {
event.preventDefault();
actions.showSettings();
};
} else {
rawTitle = 'Special:Preferences#mw-prefsection-';
rawTitle += isBetaFeature ? 'betafeatures' : 'rendering';
settingsUrl = mw.Title.newFromText( rawTitle )
.getUrl();
}
return {
settingsUrl: settingsUrl,
showSettings: showSettings,
previewDwell: actions.previewDwell,
previewAbandon: actions.abandon,
previewShow: actions.previewShow
};
};
}( mediaWiki, jQuery ) );

89
src/processLinks.js Normal file
View file

@ -0,0 +1,89 @@
( function ( mw, $ ) {
/**
* @private
*
* Gets the title of a local page from an href given some configuration.
*
* @param {String} href
* @param {mw.Map} config
* @return {String|undefined}
*/
function getTitle( href, config ) {
var linkHref,
matches,
queryLength,
titleRegex = new RegExp( mw.RegExp.escape( config.get( 'wgArticlePath' ) )
.replace( '\\$1', '(.+)' ) );
// Skip every URI that mw.Uri cannot parse
try {
linkHref = new mw.Uri( href );
} catch ( e ) {
return undefined;
}
// External links
if ( linkHref.host !== location.hostname ) {
return undefined;
}
queryLength = Object.keys( linkHref.query ).length;
// No query params (pretty URL)
if ( !queryLength ) {
matches = titleRegex.exec( linkHref.path );
return matches ? decodeURIComponent( matches[ 1 ] ) : undefined;
} else if ( queryLength === 1 && linkHref.query.hasOwnProperty( 'title' ) ) {
// URL is not pretty, but only has a `title` parameter
return linkHref.query.title;
}
return undefined;
}
/**
* Processes and returns link elements (or "`<a>`s") that are eligible for
* previews in a given container.
*
* An `<a>` is eligible for a preview if:
*
* * It has an href and a title, i.e. `<a href="/wiki/Foo" title="Foo" />`.
* * It doesn't have any blacklisted CSS classes.
* * Its href is a valid URI of a page on the local wiki.
*
* If an `<a>` is eligible, then the title of the page on the local wiki is
* stored in the `data-previews-page-title` attribute for later reuse.
*
* @param {jQuery} $container
* @param {String[]} blacklist If an `<a>` has one or more of these CSS
* classes, then it will be ignored.
* @param {mw.Map} config
*
* @return {jQuery}
*/
module.exports = function ( $container, blacklist, config ) {
var contentNamespaces;
contentNamespaces = config.get( 'wgContentNamespaces' );
return $container
.find( 'a[href][title]:not(' + blacklist.join( ', ' ) + ')' )
.filter( function () {
var title,
titleText = getTitle( this.href, config );
if ( !titleText ) {
return false;
}
// Is titleText in a content namespace?
title = mw.Title.newFromText( titleText );
if ( title && ( $.inArray( title.namespace, contentNamespaces ) >= 0 ) ) {
$( this ).data( 'page-previews-title', titleText );
return true;
}
} );
};
}( mediaWiki, jQuery ) );

View file

@ -0,0 +1,158 @@
var actionTypes = require( './../actionTypes' ),
nextState = require( './nextState' ),
counts = require( './../counts' );
/**
* Initialize the data that's shared between all events logged with [the Popups
* schema](https://meta.wikimedia.org/wiki/Schema:Popups).
*
* @param {Object} bootAction
* @return {Object}
*/
function getBaseData( bootAction ) {
var result = {
pageTitleSource: bootAction.page.title,
namespaceIdSource: bootAction.page.namespaceID,
pageIdSource: bootAction.page.id,
isAnon: bootAction.user.isAnon,
popupEnabled: bootAction.isEnabled,
pageToken: bootAction.pageToken,
sessionToken: bootAction.sessionToken,
previewCountBucket: counts.getPreviewCountBucket( bootAction.user.previewCount ),
hovercardsSuppressedByGadget: bootAction.isNavPopupsEnabled
};
if ( !bootAction.user.isAnon ) {
result.editCountBucket = counts.getEditCountBucket( bootAction.user.editCount );
}
return result;
}
/**
* Reducer for actions that may result in an event being logged with the
* Popups schema via Event Logging.
*
* TODO: For obvious reasons, this reducer and the associated change listener
* are tightly bound to the Popups schema. This reducer must be
* renamed/moved if we introduce additional instrumentation.
*
* The base data represents data that's shared between all events. Very nearly
* all of it is initialized during the BOOT action (see `getBaseData`) and
* doesn't change between link interactions, e.g. the user being an anon or
* the number of edits they've made.
*
* The user's number of previews, however, does change between link
* interactions and the associated bucket (a computed property) is what is
* logged. This is reflected in the state tree: the `previewCount` property is
* used to store the user's number of previews and the
* `baseData.previewCountBucket` property is used to store the associated
* bucket.
*
* @param {Object} state
* @param {Object} action
* @return {Object} The state as a result of processing the action
*/
module.exports = function ( state, action ) {
var nextCount, abandonEvent;
if ( state === undefined ) {
state = {
previewCount: undefined,
baseData: {},
interaction: undefined,
event: undefined
};
}
switch ( action.type ) {
case actionTypes.BOOT:
return nextState( state, {
previewCount: action.user.previewCount,
baseData: getBaseData( action ),
event: {
action: 'pageLoaded'
}
} );
case actionTypes.CHECKIN:
return nextState( state, {
event: {
action: 'checkin',
checkin: action.time
}
} );
case actionTypes.EVENT_LOGGED:
return nextState( state, {
event: undefined
} );
case actionTypes.FETCH_END:
return nextState( state, {
interaction: nextState( state.interaction, {
previewType: action.result.type
} )
} );
case actionTypes.PREVIEW_SHOW:
nextCount = state.previewCount + 1;
return nextState( state, {
previewCount: nextCount,
baseData: nextState( state.baseData, {
previewCountBucket: counts.getPreviewCountBucket( nextCount )
} ),
interaction: nextState( state.interaction, {
timeToPreviewShow: action.timestamp - state.interaction.started
} )
} );
case actionTypes.LINK_DWELL:
return nextState( state, {
interaction: {
token: action.token,
started: action.timestamp
}
} );
case actionTypes.LINK_CLICK:
return nextState( state, {
event: {
action: 'opened',
linkInteractionToken: state.interaction.token,
totalInteractionTime: Math.round( action.timestamp - state.interaction.started )
}
} );
case actionTypes.ABANDON_START:
return nextState( state, {
interaction: nextState( state.interaction, {
finished: action.timestamp
} )
} );
case actionTypes.ABANDON_END:
abandonEvent = {
linkInteractionToken: state.interaction.token,
totalInteractionTime: Math.round( state.interaction.finished - state.interaction.started )
};
// Has the preview been shown? If so, then, in the context of the
// instrumentation, then the preview has been dismissed by the user
// rather than the user has abandoned the link.
if ( state.interaction.timeToPreviewShow !== undefined ) {
abandonEvent.action = 'dismissed';
abandonEvent.previewType = state.interaction.previewType;
} else {
abandonEvent.action = 'dwelledButAbandoned';
}
return nextState( state, {
event: abandonEvent
} );
default:
return state;
}
};

5
src/reducers/index.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
eventLogging: require( './eventLogging' ),
preview: require( './preview' ),
settings: require( './settings' )
};

38
src/reducers/nextState.js Normal file
View file

@ -0,0 +1,38 @@
/**
* Creates the next state tree from the current state tree and some updates.
*
* N.B. OO.copy doesn't copy Element instances, whereas $.extend does.
* However, OO.copy does copy properties whose values are undefined or null,
* whereas $.extend doesn't. Since the state tree contains an Element instance
* - the preview.activeLink property - and we want to copy undefined/null into
* the state we need to manually iterate over updates and check with
* hasOwnProperty to copy over to the new state.
*
* In [change listeners](/doc/change_listeners.md), for example, we talk about
* the previous state and the current state (the `prevState` and `state`
* parameters, respectively). Since
* [reducers](http://redux.js.org/docs/basics/Reducers.html) take the current
* state and an action and make updates, "next state" seems appropriate.
*
* @param {Object} state
* @param {Object} updates
* @return {Object}
*/
module.exports = function ( state, updates ) {
var result = {},
key;
for ( key in state ) {
if ( state.hasOwnProperty( key ) && !updates.hasOwnProperty( key ) ) {
result[ key ] = state[ key ];
}
}
for ( key in updates ) {
if ( updates.hasOwnProperty( key ) ) {
result[ key ] = updates[ key ];
}
}
return result;
};

95
src/reducers/preview.js Normal file
View file

@ -0,0 +1,95 @@
var actionTypes = require( './../actionTypes' ),
nextState = require( './nextState' );
/**
* Reducer for actions that modify the state of the preview model
*
* @param {Object} state before action
* @param {Object} action Redux action that modified state.
* Must have `type` property.
* @return {Object} state after action
*/
module.exports = function ( state, action ) {
if ( state === undefined ) {
state = {
enabled: undefined,
activeLink: undefined,
activeEvent: undefined,
activeToken: '',
shouldShow: false,
isUserDwelling: false
};
}
switch ( action.type ) {
case actionTypes.BOOT:
return nextState( state, {
enabled: action.isEnabled
} );
case actionTypes.SETTINGS_CHANGE:
return nextState( state, {
enabled: action.enabled
} );
case actionTypes.LINK_DWELL:
// New interaction
if ( action.el !== state.activeLink ) {
return nextState( state, {
activeLink: action.el,
activeEvent: action.event,
activeToken: action.token,
// When the user dwells on a link with their keyboard, a preview is
// renderered, and then dwells on another link, the ABANDON_END
// action will be ignored.
//
// Ensure that all the preview is hidden.
shouldShow: false,
isUserDwelling: true
} );
} else {
// Dwelling back into the same link
return nextState( state, {
isUserDwelling: true
} );
}
case actionTypes.ABANDON_END:
if ( action.token === state.activeToken && !state.isUserDwelling ) {
return nextState( state, {
activeLink: undefined,
activeToken: undefined,
activeEvent: undefined,
fetchResponse: undefined,
shouldShow: false
} );
}
return state;
case actionTypes.PREVIEW_DWELL:
return nextState( state, {
isUserDwelling: true
} );
case actionTypes.ABANDON_START:
return nextState( state, {
isUserDwelling: false
} );
case actionTypes.FETCH_START:
return nextState( state, {
fetchResponse: undefined
} );
case actionTypes.FETCH_END:
if ( action.el === state.activeLink ) {
return nextState( state, {
fetchResponse: action.result,
shouldShow: true
} );
}
/* falls through */
default:
return state;
}
};

56
src/reducers/settings.js Normal file
View file

@ -0,0 +1,56 @@
var actionTypes = require( './../actionTypes' ),
nextState = require( './nextState' );
/**
* Reducer for actions that modify the state of the settings
*
* @param {Object} state
* @param {Object} action
* @return {Object} state after action
*/
module.exports = function ( state, action ) {
if ( state === undefined ) {
state = {
shouldShow: false,
showHelp: false,
shouldShowFooterLink: false
};
}
switch ( action.type ) {
case actionTypes.SETTINGS_SHOW:
return nextState( state, {
shouldShow: true,
showHelp: false
} );
case actionTypes.SETTINGS_HIDE:
return nextState( state, {
shouldShow: false,
showHelp: false
} );
case actionTypes.SETTINGS_CHANGE:
return action.wasEnabled === action.enabled ?
// If the setting is the same, just hide the dialogs
nextState( state, {
shouldShow: false
} ) :
// If the settings have changed...
nextState( state, {
// If we enabled, we just hide directly, no help
// If we disabled, keep it showing and let the ui show the help.
shouldShow: !action.enabled,
showHelp: !action.enabled,
// Since the footer link is only ever shown to anonymous users (see
// the BOOT case below), state.userIsAnon is always truthy here.
shouldShowFooterLink: !action.enabled
} );
case actionTypes.BOOT:
return nextState( state, {
shouldShowFooterLink: action.user.isAnon && !action.isEnabled
} );
default:
return state;
}
};

690
src/renderer.js Normal file
View file

@ -0,0 +1,690 @@
( function ( mw, $ ) {
var isSafari = navigator.userAgent.match( /Safari/ ) !== null,
wait = require( './wait' ),
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 );
/**
* 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.
*/
function init() {
createPokeyMasks();
}
/**
* The model of how a view is rendered, which is constructed from a response
* from the gateway.
*
* TODO: Rename `isTall` to `isPortrait`.
*
* @typedef {Object} ext.popups.Preview
* @property {jQuery} el
* @property {Boolean} hasThumbnail
* @property {Object} thumbnail
* @property {Boolean} isTall Sugar around
* `preview.hasThumbnail && thumbnail.isTall`
*/
/**
* 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 {ext.popups.PreviewModel} model
* @return {ext.popups.Preview}
*/
function render( model ) {
var preview = model.extract === undefined ? createEmptyPreview( model ) : createPreview( model );
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
* @param {Object} boundActions The
* [bound action creators](http://redux.js.org/docs/api/bindActionCreators.html)
* that were (likely) created in [boot.js](./boot.js).
* @return {jQuery.Promise}
*/
show: function ( event, boundActions ) {
return show( preview, event, boundActions );
},
/**
* Hides the preview.
*
* See `hide` for more detail.
*
* @return {jQuery.Promise}
*/
hide: function () {
return hide( preview );
}
};
}
/**
* Creates an instance of the DTO backing a preview.
*
* @param {ext.popups.PreviewModel} model
* @return {ext.popups.Preview}
*/
function createPreview( model ) {
var templateData,
thumbnail = createThumbnail( model.thumbnail ),
hasThumbnail = thumbnail !== null,
// FIXME: This should probably be moved into the gateway as we'll soon be
// fetching HTML from the API. See
// https://phabricator.wikimedia.org/T141651 for more detail.
extract = renderExtract( model.extract, model.title ),
$el;
templateData = $.extend( {}, model, {
hasThumbnail: hasThumbnail
} );
$el = mw.template.get( 'ext.popups', 'preview.mustache' )
.render( templateData );
if ( hasThumbnail ) {
$el.find( '.mwe-popups-discreet' ).append( thumbnail.el );
}
if ( extract.length ) {
$el.find( '.mwe-popups-extract' ).append( extract );
}
return {
el: $el,
hasThumbnail: hasThumbnail,
thumbnail: thumbnail,
isTall: hasThumbnail && thumbnail.isTall
};
}
/**
* Creates an instance of the DTO backing a preview. In this case the DTO
* represents a generic preview, which covers the following scenarios:
*
* * The page doesn't exist, i.e. the user hovered over a redlink or a
* redirect to a page that doesn't exist.
* * The page doesn't have a viable extract.
*
* @param {ext.popups.PreviewModel} model
* @return {ext.popups.Preview}
*/
function createEmptyPreview( model ) {
var templateData,
$el;
templateData = $.extend( {}, model, {
extractMsg: mw.msg( 'popups-preview-no-preview' ),
readMsg: mw.msg( 'popups-preview-footer-read' )
} );
$el = mw.template.get( 'ext.popups', 'preview-empty.mustache' )
.render( templateData );
return {
el: $el,
hasThumbnail: false,
isTall: false
};
}
/**
* 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`.
*
* TODO: From the perspective of the client, there's no need to distinguish
* between renderering and showing a preview. Merge #render and Preview#show.
*
* @param {ext.popups.Preview} preview
* @param {Event} event
* @param {ext.popups.PreviewBehavior} behavior
* @return {jQuery.Promise} A promise that resolves when the promise has faded
* in
*/
function show( preview, event, behavior ) {
var layout = createLayout( preview, event );
preview.el.appendTo( document.body );
// 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.hover( behavior.previewDwell, behavior.previewAbandon );
preview.el.find( '.mwe-popups-settings-icon' )
.attr( 'href', behavior.settingsUrl )
.click( behavior.showSettings );
preview.el.show();
return wait( 200 )
.then( behavior.previewShow );
}
/**
* Extracted from `mw.popups.render.closePopup`.
*
* @param {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 wait( 150 ).then( function () {
preview.el.remove();
} );
}
/**
* @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.source.indexOf( '\\' ) > -1 ||
rawThumbnail.source.indexOf( '\'' ) > -1 ||
rawThumbnail.source.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.source,
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 = [];
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.addClass( 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;
}
module.exports = {
render: render,
init: init
};
}( mediaWiki, jQuery ) );

25
src/schema.js Normal file
View file

@ -0,0 +1,25 @@
( function ( mw, $ ) {
/**
* Creates an instance of an EventLogging schema that can be used to log
* Popups events.
*
* @param {mw.Map} config
* @param {Window} window
* @return {mw.eventLog.Schema}
*/
module.exports = function ( config, window ) {
var samplingRate = config.get( 'wgPopupsSchemaSamplingRate', 0 );
if (
!window.navigator ||
!$.isFunction( window.navigator.sendBeacon ) ||
window.QUnit
) {
samplingRate = 0;
}
return new mw.eventLog.Schema( 'Popups', samplingRate );
};
}( mediaWiki, jQuery ) );

203
src/settingsDialog.js Normal file
View file

@ -0,0 +1,203 @@
( function ( mw, $ ) {
/**
* Creates a render function that will create the settings dialog and return
* a set of methods to operate on it
* @returns {Function} render function
*/
module.exports = function () {
/**
* Cached settings dialog
*
* @type {jQuery}
*/
var $dialog,
/**
* Cached settings overlay
*
* @type {jQuery}
*/
$overlay;
/**
* Renders the relevant form and labels in the settings dialog
* @param {Object} boundActions
* @returns {Object} object with methods to affect the rendered UI
*/
return function ( boundActions ) {
if ( !$dialog ) {
$dialog = createSettingsDialog();
$overlay = $( '<div>' ).addClass( 'mwe-popups-overlay' );
// Setup event bindings
$dialog.find( '.save' ).click( function () {
// Find the selected value (simple|advanced|off)
var selected = getSelectedSetting( $dialog ),
// Only simple means enabled, advanced is disabled in favor of
// NavPops and off means disabled.
enabled = selected === 'simple';
boundActions.saveSettings( enabled );
} );
$dialog.find( '.close, .okay' ).click( boundActions.hideSettings );
}
return {
/**
* Append the dialog and overlay to a DOM element
* @param {HTMLElement} el
*/
appendTo: function ( el ) {
$overlay.appendTo( el );
$dialog.appendTo( el );
},
/**
* Show the settings element and position it correctly
*/
show: function () {
var h = $( window ).height(),
w = $( window ).width();
$overlay.show();
// FIXME: Should recalc on browser resize
$dialog
.show()
.css( 'left', ( w - $dialog.outerWidth( true ) ) / 2 )
.css( 'top', ( h - $dialog.outerHeight( true ) ) / 2 );
},
/**
* Hide the settings dialog.
*/
hide: function () {
$overlay.hide();
$dialog.hide();
},
/**
* Toggle the help dialog on or off
* @param {Boolean} visible if you want to show or hide the help dialog
*/
toggleHelp: function ( visible ) {
toggleHelp( $dialog, visible );
},
/**
* Update the form depending on the enabled flag
*
* If false and no navpops, then checks 'off'
* If true, then checks 'on'
* If false, and there are navpops, then checks 'advanced'
*
* @param {Boolean} enabled if page previews are enabled
*/
setEnabled: function ( enabled ) {
var name = 'off';
if ( enabled ) {
name = 'simple';
} else if ( isNavPopupsEnabled() ) {
name = 'advanced';
}
// Check the appropiate radio button
$dialog.find( '#mwe-popups-settings-' + name )
.prop( 'checked', true );
}
};
};
};
/**
* Create the settings dialog
*
* @return {jQuery} settings dialog
*/
function createSettingsDialog() {
var $el,
path = mw.config.get( 'wgExtensionAssetsPath' ) + '/Popups/resources/ext.popups/images/',
choices = [
{
id: 'simple',
name: mw.msg( 'popups-settings-option-simple' ),
description: mw.msg( 'popups-settings-option-simple-description' ),
image: path + 'hovercard.svg',
isChecked: true
},
{
id: 'advanced',
name: mw.msg( 'popups-settings-option-advanced' ),
description: mw.msg( 'popups-settings-option-advanced-description' ),
image: path + 'navpop.svg'
},
{
id: 'off',
name: mw.msg( 'popups-settings-option-off' )
}
];
if ( !isNavPopupsEnabled() ) {
// remove the advanced option
choices.splice( 1, 1 );
}
// render the template
$el = mw.template.get( 'ext.popups', 'settings.mustache' ).render( {
heading: mw.msg( 'popups-settings-title' ),
closeLabel: mw.msg( 'popups-settings-cancel' ),
saveLabel: mw.msg( 'popups-settings-save' ),
helpText: mw.msg( 'popups-settings-help' ),
okLabel: mw.msg( 'popups-settings-help-ok' ),
descriptionText: mw.msg( 'popups-settings-description' ),
choices: choices
} );
return $el;
}
/**
* Get the selected value on the radio button
*
* @param {jQuery.Object} $el the element to extract the setting from
* @return {String} Which should be (simple|advanced|off)
*/
function getSelectedSetting( $el ) {
return $el.find(
'input[name=mwe-popups-setting]:checked, #mwe-popups-settings'
).val();
}
/**
* Toggles the visibility between a form and the help
* @param {jQuery.Object} $el element that contains form and help
* @param {Boolean} visible if the help should be visible, or the form
*/
function toggleHelp( $el, visible ) {
var $dialog = $( '#mwe-popups-settings' ),
formSelectors = 'main, .save, .close',
helpSelectors = '.mwe-popups-settings-help, .okay';
if ( visible ) {
$dialog.find( formSelectors ).hide();
$dialog.find( helpSelectors ).show();
} else {
$dialog.find( formSelectors ).show();
$dialog.find( helpSelectors ).hide();
}
}
/**
* Checks if the NavigationPopups gadget is enabled by looking at the global
* variables
* @returns {Boolean} if navpops was found to be enabled
*/
function isNavPopupsEnabled() {
/* global pg: false*/
return typeof pg !== 'undefined' && pg.fn.disablePopups !== undefined;
}
}( mediaWiki, jQuery ) );

79
src/userSettings.js Normal file
View file

@ -0,0 +1,79 @@
/**
* @typedef {Object} ext.popups.UserSettings
*/
var IS_ENABLED_KEY = 'mwe-popups-enabled',
PREVIEW_COUNT_KEY = 'ext.popups.core.previewCount';
/**
* Given the global state of the application, creates an object whose methods
* encapsulate all interactions with the given User Agent's storage.
*
* @param {mw.storage} storage The `mw.storage` singleton instance
*
* @return {ext.popups.UserSettings}
*/
module.exports = function ( storage ) {
return {
/**
* Gets whether or not the user has previously enabled Page Previews.
*
* N.B. that if the user hasn't previously enabled or disabled Page
* Previews, i.e. mw.popups.userSettings.setIsEnabled(true), then they
* are treated as if they have enabled them.
*
* @return {Boolean}
*/
getIsEnabled: function () {
return storage.get( IS_ENABLED_KEY ) !== '0';
},
/**
* Sets whether or not the user has enabled Page Previews.
*
* @param {Boolean} isEnabled
*/
setIsEnabled: function ( isEnabled ) {
storage.set( IS_ENABLED_KEY, isEnabled ? '1' : '0' );
},
/**
* Gets whether or not the user has previously enabled **or disabled**
* Page Previews.
*
* @return {Boolean}
*/
hasIsEnabled: function () {
return storage.get( IS_ENABLED_KEY, undefined ) !== undefined;
},
/**
* Gets the number of Page Previews that the user has seen.
*
* If the storage isn't available, then -1 is returned.
*
* @return {Number}
*/
getPreviewCount: function () {
var result = storage.get( PREVIEW_COUNT_KEY );
if ( result === false ) {
return -1;
} else if ( result === null ) {
return 0;
}
return parseInt( result, 10 );
},
/**
* Sets the number of Page Previews that the user has seen.
*
* @param {Number} count
*/
setPreviewCount: function ( count ) {
storage.set( PREVIEW_COUNT_KEY, count.toString() );
}
};
};

26
src/wait.js Normal file
View file

@ -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.Promise}
*/
module.exports = function ( delay ) {
var result = $.Deferred();
setTimeout( function () {
result.resolve();
}, delay );
return result.promise();
};
}( mediaWiki, jQuery ) );

7
tests/.jscsrc.js Normal file
View file

@ -0,0 +1,7 @@
/*jshint node:true */
var fs = require( 'fs' ),
config = JSON.parse( fs.readFileSync( __dirname + '/../.jscsrc' ) );
delete config.jsDoc;
module.exports = exports = config;

View file

@ -1,17 +0,0 @@
@chrome @en.m.wikipedia.beta.wmflabs.org @firefox @test2.m.wikipedia.org @vagrant @integration
Feature: Popups core
Background:
Given the hover cards test page is installed
And I am logged in
And HoverCards is enabled as a beta feature
And I am on the "Popups test page" page
And the Hovercards JavaScript module has loaded
Scenario: Hover card is visible on mouse over
And I hover over the first valid link
Then I should see a hover card
Scenario: Hover card is not visible on mouse out
And I hover over the first valid link
And I hover over the page header
Then I should not see a hover card

View file

@ -1,4 +1,4 @@
@chrome @en.m.wikipedia.beta.wmflabs.org @firefox @test2.m.wikipedia.org @vagrant @integration
@chrome @en.m.wikipedia.beta.wmflabs.org @firefox @test2.m.wikipedia.org @vagrant
Feature: Popups settings
Background:
Given the hover cards test page is installed

View file

@ -0,0 +1,15 @@
@chrome @en.m.wikipedia.beta.wmflabs.org @firefox @test2.m.wikipedia.org @vagrant @integration
Feature: Previews
Background:
Given I am logged in
And I have enabled the beta feature
And I am on the test page
Scenario: Dwelling on a valid link shows a preview
When I dwell on the first valid link
Then I should see a preview
Scenario: Abandoning the link hides the preview
When I dwell on the first valid link
And I abandon the link
Then I should not see a preview

View file

@ -3,14 +3,14 @@ class SpecialPreferencesPage
page_url 'Special:Preferences'
a(:beta_features_tab, css: '#preftab-betafeatures')
text_field(:hovercards_checkbox, css: '[name=wppopups]')
text_field(:page_previews_checkbox, css: '[name=wppopups]')
button(:submit_button, css: '#prefcontrol')
div(:notification, css: ".mw-notification")
def enable_hovercards
def enable_page_previews
beta_features_tab_element.when_present.click
return unless hovercards_checkbox_element.attribute('checked').nil?
hovercards_checkbox_element.click
return unless page_previews_checkbox_element.attribute('checked').nil?
page_previews_checkbox_element.click
submit_button_element.when_present.click
# Note well that Element#wait_until_present is more semantic but is

View file

@ -1,19 +1,13 @@
Given(/^the hover cards test page is installed$/) do
api.create_page 'Popups test page', File.read('samples/links.wikitext')
TEST_PAGE_TITLE = 'Popups test page'
Given(/^I have enabled the beta feature$/) do
visit(SpecialPreferencesPage).enable_page_previews
end
Given(/^I am on the "(.*?)" page$/) do |page|
visit(ArticlePage, using_params: { article_name: page })
end
Given(/^I am on the test page$/) do
api.create_page TEST_PAGE_TITLE, File.read('fixtures/test_page.wikitext')
Then(/^HoverCards is enabled as a beta feature$/) do
visit(SpecialPreferencesPage).enable_hovercards
end
Given(/^the Hovercards JavaScript module has loaded$/) do
on(ArticlePage) do |page|
page.wait_until do
browser.execute_script("return mw.loader.getState('ext.popups.desktop') === 'ready'")
end
visit(ArticlePage, using_params: { article_name: TEST_PAGE_TITLE }) do |page|
page.wait_until_rl_module_ready('ext.popups')
end
end

View file

@ -1,76 +0,0 @@
When(/^I hover over the page header$/) do
on(ArticlePage).page_header_element.hover
end
When(/^I hover over the first valid link$/) do
on(ArticlePage).first_valid_link_element.hover
end
When(/^I see a hover card$/) do
on(ArticlePage).hovercard_element.when_present
end
When(/^I open the popups settings dialog of the first valid link$/) do
step("I hover over the first valid link")
on(ArticlePage).settings_icon_element.when_present.click
end
When(/^I dismiss the popups settings dialog of the first valid link$/) do
on(ArticlePage).cancel_settings_button_element.when_present.click
end
When(/^I disable previews in the popups settings$/) do
on(ArticlePage) do |page|
page.settings_icon_element.when_present.click
page.disable_previews_radio_element.when_present.click
page.save_settings_button_element.when_present.click
page.settings_help_ok_button_element.when_present.click
sleep 1
end
end
When(/^I enable previews in the popups settings$/) do
step("I see the enable previews link in the footer")
on(ArticlePage) do |page|
page.last_link_in_the_footer_element.when_present.click
page.enable_previews_radio_element.when_present.click
page.save_settings_button_element.when_present.click
sleep 1
end
end
When(/^I see the enable previews link in the footer$/) do
on(ArticlePage) do |page|
page.wait_until do
page.last_link_in_the_footer_element.when_present.text.include? 'Enable previews'
end
end
end
When(/^I do not see the enable previews link in the footer$/) do
!on(ArticlePage).last_link_in_the_footer_element.when_present.text.include? 'Enable previews'
end
Then(/^I should see a hover card$/) do
expect(on(ArticlePage).hovercard_element.when_present(5)).to be_visible
end
Then(/^I should not see a hover card$/) do
# Requesting a hovercard hits API so wait time before asserting it did not show
sleep 5
expect(on(ArticlePage).hovercard_element).not_to be_visible
end
Then(/^I should see the enable previews link in the footer$/) do
on(ArticlePage) do |page|
page.wait_until do
page.last_link_in_the_footer_element.when_present.text.include? 'Enable previews'
end
expect(page.last_link_in_the_footer_element.when_present.text).to match 'Enable previews'
end
end
Then(/^I should not see the enable previews link in the footer$/) do
expect(on(ArticlePage).last_link_in_the_footer_element.when_present.text).not_to match 'Enable previews'
end

Some files were not shown because too many files have changed in this diff Show more