Update: don't prompt to create User pages

Suppress the redlink drawer for User namespace pages. The redlink drawer
prompts the user to create a missing page but this hinders the usual
workflow for User page visits specifically. A User page is connection to
an account's contributions, age, and other activities and encouraging
the creation of a missing User page when trying to view these
connections is a hindrance, especially if the missing User page is not
associated with the current user.

Bug: T201339
Change-Id: I784493a8ecf28176b5a393cb52d7bfa9fa9b1309
This commit is contained in:
Stephen Niedzielski 2019-03-04 15:19:33 -07:00 committed by Niedzielski
parent 4c10e0524d
commit 88dd2530e7
7 changed files with 524 additions and 2 deletions

View File

@ -122,11 +122,15 @@ class MinervaHooks {
"resources/skins.minerva.scripts/page-issues/page/PageIssueLink.js",
"resources/skins.minerva.scripts/page-issues/page/pageIssueFormatter.js",
'resources/skins.minerva.scripts/pageIssues.js',
'resources/skins.minerva.scripts/UriUtil.js',
'resources/skins.minerva.scripts/TitleUtil.js',
// test files
'tests/qunit/skins.minerva.scripts/downloadPageAction.test.js',
'tests/qunit/skins.minerva.scripts/pageIssuesParser.test.js',
'tests/qunit/skins.minerva.scripts/AB.test.js',
'tests/qunit/skins.minerva.scripts/pageIssues.test.js',
'tests/qunit/skins.minerva.scripts/UriUtil.test.js',
'tests/qunit/skins.minerva.scripts/TitleUtil.test.js',
'tests/qunit/skins.minerva.notifications.badge/NotificationBadge.test.js'
],
];

View File

@ -0,0 +1,138 @@
// Someone has to maintain this wherever it lives. If it live in Core, it becomes a public API.
// If it lives in some client-side target of mediawiki-title that accepts a MediaWiki config instead
// of a SiteInfo, it still becomes a public API. If it lives where used, it becomes a copy and paste
// implementation where each copy can deviate but deletion is easy. See additional discussion in
// T218358 and I95b08e77eece5cd4dae62f6f237d492d6b0fe42b.
( function ( M ) {
var UriUtil = M.require( 'skins.minerva.scripts/UriUtil' );
/**
* Returns the decoded wiki page title referenced by the passed link as a string when parsable.
* The title query parameter is returned, if present. Otherwise, a heuristic is used to attempt
* to extract the title from the path.
*
* The API is the source of truth for page titles. This function should only be used in
* circumstances where the API cannot be consulted.
*
* Assuming the current page is on metawiki, consider the following example links and
* `newFromUri()` outputs:
*
* https://meta.wikimedia.org/wiki/Foo → Foo (path title)
* http://meta.wikimedia.org/wiki/Foo → Foo (mismatching protocol)
* /wiki/Foo Foo (relative URI)
* /w/index.php?title=Foo Foo (title query parameter)
* /wiki/Talk:Foo Talk:Foo (non-main namespace URI)
* /wiki/Foo bar Foo_bar (name with spaces)
* /wiki/Foo%20bar Foo_bar (name with percent encoded spaces)
* /wiki/Foo+bar Foo+bar (name with +)
* /w/index.php?title=Foo%2bbar Foo+bar (query parameter with +)
* / null (mismatching article path)
* /wiki/index.php?title=Foo null (mismatching script path)
* https://archive.org/ → null (mismatching host)
* https://foo.wikimedia.org/ → null (mismatching host)
* https://en.wikipedia.org/wiki/Bar → null (mismatching host)
*
* This function invokes `Uri.isInternal()` to validate that this link is assuredly a local
* wiki link and that the internal usage of both the title query parameter and value of
* wgArticlePath are relevant.
*
* This function doesn't throw. `null` is returned for any unparseable input.
*
* @param {mw.Uri|Object|string} [uri] Passed to Uri.
* @param {Object|boolean} [options] Passed to Uri.
* @param {Object|boolean} [options.validateReadOnlyLink] If true, only links that would show a
* page for reading are considered. E.g., `/wiki/Foo` and `/w/index.php?title=Foo` would
* validate but `/w/index.php?title=Foo&action=bar` would not.
* @return {mw.Title|null} A Title or `null`.
*/
function newFromUri( uri, options ) {
var
mwUri,
regExp,
matches,
title;
try {
// uri may or may not be a Uri but the Uri constructor accepts a Uri parameter.
mwUri = new mw.Uri( uri, options );
} catch ( e ) {
return null;
}
if ( !UriUtil.isInternal( mwUri ) ) {
return null;
}
if ( ( options || {} ).validateReadOnlyLink && !isReadOnlyUri( mwUri ) ) {
// An unknown query parameter is used. This may not be a read-only link.
return null;
}
if ( mwUri.query.title ) {
// True if input starts with wgScriptPath.
regExp = new RegExp( '^' + mw.RegExp.escape( mw.config.get( 'wgScriptPath' ) ) + '/' );
// URL has a nonempty `title` query parameter like `/w/index.php?title=Foo`. The script
// path should match.
matches = regExp.test( mwUri.path );
if ( !matches ) {
return null;
}
// The parameter was already decoded at Uri construction.
title = mwUri.query.title;
} else {
// True if input starts with wgArticlePath and ends with a nonempty page title. The
// first matching group (index 1) is the page title.
regExp = new RegExp( '^' + mw.RegExp.escape( mw.config.get( 'wgArticlePath' ) ).replace( '\\$1', '(.+)' ) );
// No title query parameter is present so the URL may be "pretty" like `/wiki/Foo`.
// `Uri.path` should not contain query parameters or a fragment, as is assumed in
// `Uri.getRelativePath()`. Try to isolate the title.
matches = regExp.exec( mwUri.path );
if ( !matches || !matches[ 1 ] ) {
return null;
}
try {
// `Uri.path` was not previously decoded, as is assumed in `Uri.getRelativePath()`,
// and decoding may now fail. Do not use `Uri.decode()` which is designed to be
// paired with `Uri.encode()` and replaces `+` characters with spaces.
title = decodeURIComponent( matches[ 1 ] );
} catch ( e ) {
return null;
}
}
// Append the fragment, if present.
title += mwUri.fragment ? '#' + mwUri.fragment : '';
return mw.Title.newFromText( title );
}
/**
* Validates that the passed link is for reading.
*
* The following links return true:
* /wiki/Foo
* /w/index.php?title=Foo
* /w/index.php?oldid=123
*
* The following links return false:
* /w/index.php?title=Foo&action=bar
*
* @private
* @static
* @method isReadOnlyUri
* @param {mw.Uri} uri A Uri to an internal wiki page.
* @return {boolean} True if uri has no query parameters or only known parameters for reading.
*/
function isReadOnlyUri( uri ) {
var length = Object.keys( uri.query ).length;
return length === ( ( 'oldid' in uri.query ? 1 : 0 ) + ( 'title' in uri.query ? 1 : 0 ) );
}
M.define( 'skins.minerva.scripts/TitleUtil', {
newFromUri: newFromUri
} );
}( mw.mobileFrontend ) );

View File

@ -0,0 +1,34 @@
( function ( M ) {
/**
* Compares the default Uri host, usually `window.location.host`, and `mw.Uri.host`. Equivalence
* tests internal linkage, a mismatch may indicate an external link. Interwiki links are
* considered external.
*
* This function only indicates internal in the sense of being on the same host or not. It has
* no knowledge of [[Link]] vs [Link] links.
*
* On https://meta.wikimedia.org/wiki/Foo, the following links would be considered *internal*
* and return `true`:
*
* https://meta.wikimedia.org/
* https://meta.wikimedia.org/wiki/Bar
* https://meta.wikimedia.org/w/index.php?title=Bar
*
* Similarly, the following links would be considered *not* internal and return `false`:
*
* https://archive.org/
* https://foo.wikimedia.org/
* https://en.wikipedia.org/
* https://en.wikipedia.org/wiki/Bar
*
* @param {mw.Uri} uri
* @return {boolean}
*/
function isInternal( uri ) {
return uri.host === mw.Uri().host;
}
M.define( 'skins.minerva.scripts/UriUtil', {
isInternal: isInternal
} );
}( mw.mobileFrontend ) );

View File

@ -5,6 +5,7 @@
toast = mobile.toast,
time = mobile.time,
skin = M.require( 'mobile.init/skin' ),
TitleUtil = M.require( 'skins.minerva.scripts/TitleUtil' ),
issues = M.require( 'skins.minerva.scripts/pageIssues' ),
downloadPageAction = M.require( 'skins.minerva.scripts/downloadPageAction' ),
loader = mobile.rlModuleLoader,
@ -16,9 +17,11 @@
Anchor = mobile.Anchor,
overlayManager = OverlayManager.getSingleton(),
page = M.getCurrentPage(),
redLinks = page.getRedLinks(),
api = new mw.Api(),
thumbs = page.getThumbnails(),
eventBus = mobile.eventBusSingleton;
eventBus = mobile.eventBusSingleton,
namespaceIDs = mw.config.get( 'wgNamespaceIds' );
/**
* Event handler for clicking on an image thumbnail
@ -255,16 +258,78 @@
}
}
/**
* Tests a URL to determine if it links to a local User namespace page or not.
*
* Assuming the current page visited is hosted on metawiki, the following examples would return
* true:
*
* https://meta.wikimedia.org/wiki/User:Foo
* /wiki/User:Foo
* /wiki/User:Nonexistent_user_page
*
* The following examples return false:
*
* https://en.wikipedia.org/wiki/User:Foo
* /wiki/Foo
* /wiki/User_talk:Foo
*
* @param {string} url
* @return {boolean}
*/
function isUserUri( url ) {
var
title = TitleUtil.newFromUri( url ),
namespace = title ? title.getNamespaceId() : undefined;
return namespace === namespaceIDs.user;
}
/**
* Strip the edit action from red links to nonexistent User namespace pages.
* @return {void}
*/
function initUserRedLinks() {
redLinks.filter( function ( _, element ) {
// Filter out non-User namespace pages.
return isUserUri( element.href );
} ).each( function ( _, element ) {
var uri = new mw.Uri( element.href );
if ( uri.query.action !== 'edit' ) {
// Nothing to strip.
return;
}
// Strip the action.
delete uri.query.action;
// Update the element with the new link.
element.href = uri.toString();
} );
}
/**
* Initialize red links call-to-action
*
* Upon clicking a red link, show an interstitial CTA explaining that the page doesn't exist
* with a button to create it, rather than directly navigate to the edit form.
*
* Special case T201339: following a red link to a user or user talk page should not prompt for
* its creation. The reasoning is that user pages should be created by their owners and it's far
* more common that non-owners follow a user's red linked user page to consider their
* contributions, account age, or other activity.
*
* For example, a user adds a section to a Talk page and signs their contribution (which creates
* a link to their user page whether exists or not). If the user page does not exist, that link
* will be red. In both cases, another user follows this link, not to edit create a page for
* that user but to obtain information on them.
*
* @ignore
*/
function initRedlinksCta() {
page.getRedLinks().on( 'click', function ( ev ) {
redLinks.filter( function ( _, element ) {
// Filter out local User namespace pages.
return !isUserUri( element.href );
} ).on( 'click', function ( ev ) {
var drawerOptions = {
progressiveButton: new Button( {
progressive: true,
@ -337,6 +402,7 @@
initHistoryLink( $( '.last-modifier-tagline a' ) );
appendDownloadButton();
initRedlinksCta();
initUserRedLinks();
initEditLink();
// Setup the issues banner on the page
// Pages which dont exist (id 0) cannot have issues

View File

@ -380,7 +380,9 @@
"skins.minerva.icons.images.scripts",
"mediawiki.util",
"mediawiki.router",
"mediawiki.RegExp",
"mediawiki.Title",
"mediawiki.Uri",
"mobile.startup",
"mediawiki.user",
"mediawiki.storage",
@ -427,6 +429,8 @@
"resources/skins.minerva.scripts/page-issues/page/PageIssueLink.js",
"resources/skins.minerva.scripts/page-issues/page/pageIssueFormatter.js",
"resources/skins.minerva.scripts/pageIssues.js",
"resources/skins.minerva.scripts/UriUtil.js",
"resources/skins.minerva.scripts/TitleUtil.js",
"resources/skins.minerva.scripts/init.js",
"resources/skins.minerva.scripts/initLogging.js",
"resources/skins.minerva.scripts/mobileRedirect.js",

View File

@ -0,0 +1,229 @@
( function ( M ) {
var TitleUtil = M.require( 'skins.minerva.scripts/TitleUtil' );
QUnit.module( 'Minerva TitleUtil', QUnit.newMwEnvironment( {
setup: function () {
this.mwUriOrg = mw.Uri;
mw.Uri = mw.UriRelative( 'https://meta.wikimedia.org/w/index.php' );
},
teardown: function () {
mw.Uri = this.mwUriOrg;
delete this.mwUriOrg;
},
config: {
wgArticlePath: '/wiki/$1',
wgScriptPath: '/w'
}
} ) );
QUnit.test( '.newFromUri()', function ( assert ) {
[ '', 'https://meta.wikimedia.org' ].forEach( function ( authority, index ) {
var
indexMsg = 'case ' + index + ' ',
authorityMsg = ' authority="' + authority + '"',
validateReadOnlyLink = { validateReadOnlyLink: true };
assert.strictEqual(
TitleUtil.newFromUri( authority + '/w/index.php?title=Title' ).getPrefixedDb(),
'Title',
indexMsg + 'title is in query parameter' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/wiki/Title' ).getPrefixedDb(),
'Title',
indexMsg + 'title is in path' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/foo/bar/wiki/Title' ),
null,
indexMsg + 'title is not in nonmatching path' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/wiki/%E6%B8%AC%E8%A9%A6' ).getPrefixedDb(),
'測試',
indexMsg + 'title is decoded' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/wiki/Foo bar' ).getPrefixedDb(),
'Foo_bar',
indexMsg + 'title with space is decoded' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/wiki/Foo%20bar' ).getPrefixedDb(),
'Foo_bar',
indexMsg + 'title with encoded space is decoded' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/w/index.php?title=Title#fragment' ).getPrefixedDb(),
'Title',
indexMsg + 'fragment is omitted from query title' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/wiki/Title#fragment' ).getPrefixedDb(),
'Title',
indexMsg + 'fragment is omitted from path title' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/w/index.php?title=Title#fragment' ).getFragment(),
'fragment',
indexMsg + 'fragment is present after query parameter' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/wiki/Title#fragment' ).getFragment(),
'fragment',
indexMsg + 'fragment is present after path' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/w/index.php?title=Title#foo%20bar' ).getFragment(),
'foo bar',
indexMsg + 'fragment is decoded' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/w/index.php?title=Title', validateReadOnlyLink ).getPrefixedDb(),
'Title',
indexMsg + 'query title is read-only' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/wiki/Title', validateReadOnlyLink ).getPrefixedDb(),
'Title',
indexMsg + 'path title is read-only' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/w/index.php?title=Title&oldid=123', validateReadOnlyLink ).getPrefixedDb(),
'Title',
indexMsg + 'query title with revision is read-only' + authorityMsg
);
assert.strictEqual(
TitleUtil.newFromUri( authority + '/w/index.php?title=Title&param', validateReadOnlyLink ),
null,
indexMsg + 'query title with unknown parameter is not read-only' + authorityMsg
);
} );
// Bad or odd inputs.
[
'%', null, undefined, '', ' ', '/', {}, '\\', '/wiki/%', '/w/index.php?title=%'
].forEach( function ( input, index ) {
assert.strictEqual(
TitleUtil.newFromUri( input ),
null,
'Case ' + index + ' no Title in bad input input="' + input + '"'
);
} );
// Parameters are passed to Uri's constructor.
assert.strictEqual(
TitleUtil.newFromUri( { protocol: 'https',
host: 'meta.wikimedia.org',
path: '/wiki/Title' } ).getPrefixedDb(),
'Title',
'title is in Uri parameters'
);
// A Uri itself can be passed.
assert.strictEqual(
TitleUtil.newFromUri( new mw.Uri( 'https://meta.wikimedia.org/wiki/Title' ) ).getPrefixedDb(),
'Title',
'title is in Uri'
);
// JSDoc examples.
// https://meta.wikimedia.org/wiki/Foo → Foo (path title)
assert.strictEqual(
TitleUtil.newFromUri( 'https://meta.wikimedia.org/wiki/Foo' ).getPrefixedDb(),
'Foo',
'path title'
);
// http://meta.wikimedia.org/wiki/Foo → Foo (mismatching protocol)
assert.strictEqual(
TitleUtil.newFromUri( 'http://meta.wikimedia.org/wiki/Foo' ).getPrefixedDb(),
'Foo',
'mismatching protocol'
);
// /wiki/Foo → Foo (relative URI)
assert.strictEqual(
TitleUtil.newFromUri( '/wiki/Foo' ).getPrefixedDb(),
'Foo',
'relative URI'
);
// /w/index.php?title=Foo → Foo (title query parameter)
assert.strictEqual(
TitleUtil.newFromUri( '/w/index.php?title=Foo' ).getPrefixedDb(),
'Foo',
'title query parameter'
);
// /wiki/Talk:Foo → Talk:Foo (non-main namespace URI)
assert.strictEqual(
TitleUtil.newFromUri( '/wiki/Talk:Foo' ).getPrefixedDb(),
'Talk:Foo',
'non-main namespace URI'
);
// /wiki/Foo bar → Foo_bar (name with spaces)
assert.strictEqual(
TitleUtil.newFromUri( '/wiki/Foo bar' ).getPrefixedDb(),
'Foo_bar',
'name with spaces'
);
// /wiki/Foo%20bar → Foo_bar (name with percent encoded spaces)
assert.strictEqual(
TitleUtil.newFromUri( '/wiki/Foo%20bar' ).getPrefixedDb(),
'Foo_bar',
'name with percent encoded spaces'
);
// /wiki/Foo+bar → Foo+bar (name with +)
assert.strictEqual(
TitleUtil.newFromUri( '/wiki/Foo+bar' ).getPrefixedDb(),
'Foo+bar',
'name with +'
);
// /w/index.php?title=Foo%2bbar → Foo+bar (query parameter with +)
assert.strictEqual(
TitleUtil.newFromUri( '/w/index.php?title=Foo%2bbar' ).getPrefixedDb(),
'Foo+bar',
'query parameter with +'
);
// / → null (mismatching article path)
assert.strictEqual(
TitleUtil.newFromUri( '/' ),
null,
'mismatching article path'
);
// /wiki/index.php?title=Foo → null (mismatching script path)
assert.strictEqual(
TitleUtil.newFromUri( '/wiki/index.php?title=Foo' ),
null,
'mismatching script path'
);
// https://archive.org/ → null (mismatching host)
assert.strictEqual(
TitleUtil.newFromUri( 'https://archive.org/' ),
null,
'mismatching host (0)'
);
// https://foo.wikimedia.org/ → null (mismatching host)
assert.strictEqual(
TitleUtil.newFromUri( 'https://foo.wikimedia.org/' ),
null,
'mismatching host (1)'
);
// https://en.wikipedia.org/wiki/Bar → null (mismatching host)
assert.strictEqual(
TitleUtil.newFromUri( 'https://en.wikipedia.org/wiki/Bar' ),
null,
'mismatching host (2)'
);
} );
}( mw.mobileFrontend ) );

View File

@ -0,0 +1,47 @@
( function ( M ) {
var UriUtil = M.require( 'skins.minerva.scripts/UriUtil' );
QUnit.module( 'Minerva UriUtil', QUnit.newMwEnvironment( {
setup: function () {
this.mwUriOrg = mw.Uri;
mw.Uri = mw.UriRelative( 'https://meta.wikimedia.org/w/index.php' );
},
teardown: function () {
mw.Uri = this.mwUriOrg;
delete this.mwUriOrg;
}
} ) );
QUnit.test( '.isInternal()', function ( assert ) {
assert.strictEqual(
UriUtil.isInternal( new mw.Uri( '/relative' ) ),
true,
'relative URLs are internal'
);
assert.strictEqual(
UriUtil.isInternal( new mw.Uri( 'http://meta.wikimedia.org/' ) ),
true,
'matching hosts are internal'
);
assert.strictEqual(
UriUtil.isInternal( new mw.Uri( 'https:/meta.wikimedia.org/' ) ),
true,
'protocol is irrelevant'
);
assert.strictEqual(
UriUtil.isInternal( new mw.Uri( 'https://meta.wikimedia.org/path' ) ),
true,
'path is irrelevant'
);
assert.strictEqual(
UriUtil.isInternal( new mw.Uri( 'https://archive.org/' ) ),
false,
'external links are not internal'
);
assert.strictEqual(
UriUtil.isInternal( new mw.Uri( 'https://www.meta.wikimedia.org/' ) ),
false,
'differing subdomains are not internal'
);
} );
}( mw.mobileFrontend ) );