mediawiki-extensions-Discus.../modules/dt-ve/dt.ui.UsernameCompletionAction.js
Ed Sanders d7e56679a4 UsernameCompletionAction: Tell widget to remove characters when using insertAndOpen
Change-Id: Iefad1e10feeb67b9c80580d62bd6215e839c2303
2023-06-02 14:22:37 +01:00

275 lines
8.8 KiB
JavaScript

/*!
* VisualEditor UserInterface MWUsernameCompletionAction class.
*
* @copyright 2011-2019 VisualEditor Team and others; see http://ve.mit-license.org
*/
var sequence,
controller = require( 'ext.discussionTools.init' ).controller;
function sortAuthors( a, b ) {
return a.username < b.username ? -1 : ( a.username === b.username ? 0 : 1 );
}
function hasUser( authors, username ) {
return authors.some( function ( author ) {
return author.username === username;
} );
}
/**
* MWUsernameCompletionAction action.
*
* Controls autocompletion of usernames
*
* @class
* @extends ve.ui.CompletionAction
* @constructor
* @param {ve.ui.Surface} surface Surface to act on
* @param {string} [source]
*/
function MWUsernameCompletionAction() {
var action = this;
// Parent constructor
MWUsernameCompletionAction.super.apply( this, arguments );
// Shared API object so previous requests can be aborted
this.api = controller.getApi();
this.searchedPrefixes = {};
this.localUsers = [];
this.ipUsers = [];
this.surface.authors.forEach( function ( author ) {
if ( mw.util.isIPAddress( author.username ) ) {
action.ipUsers.push( author );
} else if ( author.username !== mw.user.getName() ) {
action.localUsers.push( author );
}
} );
// On user talk pages, always list the "owner" of the talk page
var relevantUserName = mw.config.get( 'wgRelevantUserName' );
if (
relevantUserName &&
relevantUserName !== mw.user.getName() &&
!hasUser( this.localUsers, relevantUserName )
) {
this.localUsers.push( {
username: relevantUserName,
displayNames: []
} );
this.localUsers.sort( sortAuthors );
}
this.remoteUsers = [];
this.sequenceAdded = false;
}
/* Inheritance */
OO.inheritClass( MWUsernameCompletionAction, ve.ui.CompletionAction );
/* Static Properties */
MWUsernameCompletionAction.static.name = 'mwUsernameCompletion';
MWUsernameCompletionAction.static.methods = OO.copy( MWUsernameCompletionAction.static.methods );
MWUsernameCompletionAction.static.methods.push( 'insertAndOpen' );
/* Methods */
MWUsernameCompletionAction.prototype.insertAndOpen = function () {
var inserted = false,
surfaceModel = this.surface.getModel(),
fragment = surfaceModel.getFragment();
// This is opening a window in a slightly weird way, so the normal logging
// doesn't catch it. This assumes that the only way to get here is from
// the tool. If we add other paths, we'd need to change the logging.
ve.track(
'activity.' + this.constructor.static.name,
{ action: 'window-open-from-tool' }
);
// Run the sequence matching logic again to check
// if we already have the sequence inserted at the
// current offset.
if ( fragment.getSelection().isCollapsed() ) {
inserted = this.surface.getView().findMatchingSequences().some( function ( item ) {
return item.sequence === sequence;
} );
}
if ( !inserted ) {
fragment.insertContent( '@' );
}
fragment.collapseToEnd().select();
this.sequenceAdded = true;
return this.open();
};
MWUsernameCompletionAction.prototype.getSequenceLength = function () {
if ( this.sequenceAdded ) {
return this.constructor.static.sequenceLength;
}
// Parent method
return MWUsernameCompletionAction.super.prototype.getSequenceLength.apply( this, arguments );
};
MWUsernameCompletionAction.prototype.getSuggestions = function ( input ) {
var title = mw.Title.makeTitle( mw.config.get( 'wgNamespaceIds' ).user, input ),
validatedInput = title ? input : '',
action = this;
this.api.abort(); // Abort all unfinished API requests
var apiPromise;
if ( input.length > 0 && !this.searchedPrefixes[ input ] ) {
apiPromise = this.api.get( {
action: 'query',
list: 'allusers',
auprefix: input,
auprop: 'blockinfo',
auwitheditsonly: 1,
// Fetch twice as many results as we need so we can filter
// blocked users and still probably have some suggestions left
aulimit: this.constructor.static.defaultLimit * 2
} ).then( function ( response ) {
var suggestions = response.query.allusers.filter( function ( user ) {
// API doesn't return IPs
return !hasUser( action.localUsers, user.name ) &&
!hasUser( action.remoteUsers, user.name ) &&
// Exclude users with indefinite sitewide blocks:
// The only place such users could reply is on their
// own user talk page, and in that case the user
// will be included in localUsers.
!( user.blockexpiry === 'infinite' && !user.blockpartial );
} ).map( function ( user ) {
return {
username: user.name,
displayNames: []
};
} );
action.remoteUsers.push.apply( action.remoteUsers, suggestions );
action.remoteUsers.sort( sortAuthors );
action.searchedPrefixes[ input ] = true;
} );
} else {
apiPromise = ve.createDeferred().resolve().promise();
}
return apiPromise.then( function () {
// By concatenating on-thread authors and remote-fetched authors, both
// sorted alphabetically, we'll get our suggestion popup sorted so all
// on-thread matches come first.
return action.filterSuggestionsForInput(
action.localUsers
// Show no remote users if no input provided
.concat( input.length > 0 ? action.remoteUsers : [] ),
// TODO: Consider showing IP users
// * Change link to Special:Contributions/<ip> (localised)
// * Let users know that mentioning an IP will not create a notification?
// .concat( this.ipUsers )
validatedInput
);
} );
};
/**
* Compare a suggestion to the normalized user input (lower case)
*
* @param {Mixed} suggestion Suggestion data, string by default
* @param {string} normalizedInput Noramlized user input
* @return {Object} Match object, containing two booleans, `isMatch` and `isExact`
*/
MWUsernameCompletionAction.prototype.compareSuggestionToInput = function ( suggestion, normalizedInput ) {
var normalizedSuggestion = suggestion.username.toLowerCase() + ' ' +
suggestion.displayNames.map( function ( displayName ) {
return displayName.toLowerCase();
} ).join( ' ' );
return {
isMatch: normalizedSuggestion.indexOf( normalizedInput ) !== -1,
isExact: normalizedSuggestion === normalizedInput
};
};
/**
* Create a suggestion from an input
*
* @param {string} input User input
* @return {Mixed} Suggestion data, string by default
*/
MWUsernameCompletionAction.prototype.createSuggestion = function ( input ) {
return {
username: input,
displayNames: []
};
};
MWUsernameCompletionAction.prototype.getMenuItemForSuggestion = function ( suggestion ) {
return new OO.ui.MenuOptionWidget( { data: suggestion.username, label: suggestion.username } );
};
MWUsernameCompletionAction.prototype.getHeaderLabel = function ( input, suggestions ) {
if ( suggestions === undefined ) {
var $query = $( '<span>' ).text( input );
return mw.message( 'discussiontools-replywidget-mention-tool-header', $query ).parseDom();
}
};
MWUsernameCompletionAction.prototype.insertCompletion = function ( word, range ) {
var prefix = mw.msg( 'discussiontools-replywidget-mention-prefix' ),
suffix = mw.msg( 'discussiontools-replywidget-mention-suffix' ),
title = mw.Title.newFromText( word, mw.config.get( 'wgNamespaceIds' ).user );
if ( this.surface.getMode() === 'source' ) {
// TODO: this should be configurable per-wiki so that e.g. custom templates can be used
word = prefix + '[[' + title.getPrefixedText() + '|' + word + ']]' + suffix;
return MWUsernameCompletionAction.super.prototype.insertCompletion.call( this, word, range );
}
var fragment = this.surface.getModel().getLinearFragment( range, true );
fragment.removeContent().insertContent( [
{ type: 'mwPing', attributes: { user: word } },
{ type: '/mwPing' }
] );
fragment.collapseToEnd();
return fragment;
};
MWUsernameCompletionAction.prototype.shouldAbandon = function ( input ) {
// TODO: need to consider whether pending loads from server are happening here
return MWUsernameCompletionAction.super.prototype.shouldAbandon.apply( this, arguments ) && (
// Abandon if the user hit space immediately
input.match( /^\s+$/ ) ||
// Abandon if there's more than two words entered without a match
input.split( /\s+/ ).length > 2
);
};
/* Registration */
ve.ui.actionFactory.register( MWUsernameCompletionAction );
var openCommand = new ve.ui.Command(
'openMWUsernameCompletions', MWUsernameCompletionAction.static.name, 'open',
{ supportedSelections: [ 'linear' ] }
);
var insertAndOpenCommand = new ve.ui.Command(
'insertAndOpenMWUsernameCompletions', MWUsernameCompletionAction.static.name, 'insertAndOpen',
{ supportedSelections: [ 'linear' ] }
);
sequence = new ve.ui.Sequence( 'autocompleteMWUsernames', 'openMWUsernameCompletions', '@', 0 );
ve.ui.commandRegistry.register( openCommand );
ve.ui.commandRegistry.register( insertAndOpenCommand );
ve.ui.wikitextCommandRegistry.register( openCommand );
ve.ui.wikitextCommandRegistry.register( insertAndOpenCommand );
ve.ui.sequenceRegistry.register( sequence );
ve.ui.wikitextSequenceRegistry.register( sequence );
module.exports = MWUsernameCompletionAction;