2024-05-24 12:31:30 +00:00
|
|
|
const controller = require( 'ext.discussionTools.init' ).controller;
|
2024-05-24 12:43:07 +00:00
|
|
|
let sequence = null;
|
2020-10-23 11:02:18 +00:00
|
|
|
|
2022-09-06 14:41:38 +00:00
|
|
|
function sortAuthors( a, b ) {
|
|
|
|
return a.username < b.username ? -1 : ( a.username === b.username ? 0 : 1 );
|
|
|
|
}
|
|
|
|
|
|
|
|
function hasUser( authors, username ) {
|
2024-04-19 22:07:35 +00:00
|
|
|
return authors.some( ( author ) => author.username === username );
|
2022-09-06 14:41:38 +00:00
|
|
|
}
|
|
|
|
|
2019-12-10 16:18:24 +00:00
|
|
|
/**
|
|
|
|
* MWUsernameCompletionAction action.
|
|
|
|
*
|
|
|
|
* Controls autocompletion of usernames
|
|
|
|
*
|
|
|
|
* @class
|
|
|
|
* @extends ve.ui.CompletionAction
|
|
|
|
* @constructor
|
|
|
|
* @param {ve.ui.Surface} surface Surface to act on
|
2023-05-24 15:44:24 +00:00
|
|
|
* @param {string} [source]
|
2019-12-10 16:18:24 +00:00
|
|
|
*/
|
2023-05-24 15:44:24 +00:00
|
|
|
function MWUsernameCompletionAction() {
|
2019-12-10 16:18:24 +00:00
|
|
|
// Parent constructor
|
2023-05-24 15:44:24 +00:00
|
|
|
MWUsernameCompletionAction.super.apply( this, arguments );
|
2019-12-10 16:18:24 +00:00
|
|
|
|
2020-10-23 11:02:18 +00:00
|
|
|
// Shared API object so previous requests can be aborted
|
|
|
|
this.api = controller.getApi();
|
2019-12-10 16:18:24 +00:00
|
|
|
this.searchedPrefixes = {};
|
2020-05-06 17:52:23 +00:00
|
|
|
this.localUsers = [];
|
|
|
|
this.ipUsers = [];
|
2024-04-18 18:37:58 +00:00
|
|
|
this.surface.authors.forEach( ( author ) => {
|
2022-09-06 14:41:38 +00:00
|
|
|
if ( mw.util.isIPAddress( author.username ) ) {
|
2024-04-18 18:37:58 +00:00
|
|
|
this.ipUsers.push( author );
|
2022-09-06 14:41:38 +00:00
|
|
|
} else if ( author.username !== mw.user.getName() ) {
|
2024-04-18 18:37:58 +00:00
|
|
|
this.localUsers.push( author );
|
2020-05-06 17:52:23 +00:00
|
|
|
}
|
|
|
|
} );
|
2022-09-06 14:41:38 +00:00
|
|
|
// On user talk pages, always list the "owner" of the talk page
|
2024-05-24 12:20:50 +00:00
|
|
|
const relevantUserName = mw.config.get( 'wgRelevantUserName' );
|
2021-03-04 20:59:00 +00:00
|
|
|
if (
|
|
|
|
relevantUserName &&
|
|
|
|
relevantUserName !== mw.user.getName() &&
|
2022-09-06 14:41:38 +00:00
|
|
|
!hasUser( this.localUsers, relevantUserName )
|
2021-03-04 20:59:00 +00:00
|
|
|
) {
|
2022-09-06 14:41:38 +00:00
|
|
|
this.localUsers.push( {
|
|
|
|
username: relevantUserName,
|
|
|
|
displayNames: []
|
|
|
|
} );
|
|
|
|
this.localUsers.sort( sortAuthors );
|
2021-02-09 21:51:28 +00:00
|
|
|
}
|
2019-12-10 16:18:24 +00:00
|
|
|
this.remoteUsers = [];
|
2023-06-02 13:22:37 +00:00
|
|
|
this.sequenceAdded = false;
|
2019-12-10 16:18:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/* Inheritance */
|
|
|
|
|
|
|
|
OO.inheritClass( MWUsernameCompletionAction, ve.ui.CompletionAction );
|
|
|
|
|
|
|
|
/* Static Properties */
|
|
|
|
|
|
|
|
MWUsernameCompletionAction.static.name = 'mwUsernameCompletion';
|
|
|
|
|
2020-06-11 14:56:17 +00:00
|
|
|
MWUsernameCompletionAction.static.methods = OO.copy( MWUsernameCompletionAction.static.methods );
|
|
|
|
MWUsernameCompletionAction.static.methods.push( 'insertAndOpen' );
|
|
|
|
|
2019-12-10 16:18:24 +00:00
|
|
|
/* Methods */
|
|
|
|
|
2020-06-11 14:56:17 +00:00
|
|
|
MWUsernameCompletionAction.prototype.insertAndOpen = function () {
|
2024-05-24 12:43:07 +00:00
|
|
|
let inserted = false;
|
|
|
|
const surfaceModel = this.surface.getModel(),
|
2021-03-24 11:30:11 +00:00
|
|
|
fragment = surfaceModel.getFragment();
|
2021-03-19 15:48:23 +00:00
|
|
|
|
2020-06-23 15:23:13 +00:00
|
|
|
// 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' }
|
|
|
|
);
|
2021-03-19 15:48:23 +00:00
|
|
|
|
|
|
|
// Run the sequence matching logic again to check
|
|
|
|
// if we already have the sequence inserted at the
|
|
|
|
// current offset.
|
2021-03-24 11:30:11 +00:00
|
|
|
if ( fragment.getSelection().isCollapsed() ) {
|
2024-04-19 22:07:35 +00:00
|
|
|
inserted = this.surface.getView().findMatchingSequences()
|
|
|
|
.some( ( item ) => item.sequence === sequence );
|
2021-03-19 15:48:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if ( !inserted ) {
|
|
|
|
fragment.insertContent( '@' );
|
|
|
|
}
|
|
|
|
fragment.collapseToEnd().select();
|
|
|
|
|
2023-06-02 13:22:37 +00:00
|
|
|
this.sequenceAdded = true;
|
|
|
|
|
2020-06-22 11:22:08 +00:00
|
|
|
return this.open();
|
2020-06-11 14:56:17 +00:00
|
|
|
};
|
|
|
|
|
2023-06-02 13:22:37 +00:00
|
|
|
MWUsernameCompletionAction.prototype.getSequenceLength = function () {
|
|
|
|
if ( this.sequenceAdded ) {
|
|
|
|
return this.constructor.static.sequenceLength;
|
|
|
|
}
|
|
|
|
// Parent method
|
|
|
|
return MWUsernameCompletionAction.super.prototype.getSequenceLength.apply( this, arguments );
|
|
|
|
};
|
|
|
|
|
2019-12-10 16:18:24 +00:00
|
|
|
MWUsernameCompletionAction.prototype.getSuggestions = function ( input ) {
|
2024-05-24 12:20:50 +00:00
|
|
|
const title = mw.Title.makeTitle( mw.config.get( 'wgNamespaceIds' ).user, input ),
|
2024-11-25 17:15:58 +00:00
|
|
|
validatedInput = title ? input : '';
|
2019-12-10 16:18:24 +00:00
|
|
|
|
|
|
|
this.api.abort(); // Abort all unfinished API requests
|
2024-05-24 12:20:50 +00:00
|
|
|
let apiPromise;
|
2021-09-20 07:28:34 +00:00
|
|
|
if ( input.length > 0 && !this.searchedPrefixes[ input ] ) {
|
2020-07-02 14:12:30 +00:00
|
|
|
apiPromise = this.api.get( {
|
2019-12-10 16:18:24 +00:00
|
|
|
action: 'query',
|
|
|
|
list: 'allusers',
|
2021-09-20 07:28:34 +00:00
|
|
|
auprefix: input,
|
2021-11-01 14:56:16 +00:00
|
|
|
auprop: 'blockinfo',
|
2021-12-22 23:57:15 +00:00
|
|
|
auwitheditsonly: 1,
|
2021-11-01 15:29:39 +00:00
|
|
|
// 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
|
2024-04-18 18:37:58 +00:00
|
|
|
} ).then( ( response ) => {
|
2024-05-24 12:20:50 +00:00
|
|
|
const suggestions = response.query.allusers.filter(
|
2020-05-06 17:52:23 +00:00
|
|
|
// API doesn't return IPs
|
2024-11-25 17:15:58 +00:00
|
|
|
( user ) => !hasUser( this.localUsers, user.name ) &&
|
|
|
|
!hasUser( this.remoteUsers, user.name ) &&
|
2021-11-01 14:56:16 +00:00
|
|
|
// 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.
|
2024-04-19 22:07:35 +00:00
|
|
|
!( user.blockexpiry === 'infinite' && !user.blockpartial )
|
|
|
|
).map( ( user ) => ( {
|
|
|
|
username: user.name,
|
|
|
|
displayNames: []
|
|
|
|
} ) );
|
2019-12-10 16:18:24 +00:00
|
|
|
|
2024-11-25 17:15:58 +00:00
|
|
|
this.remoteUsers.push.apply( this.remoteUsers, suggestions );
|
|
|
|
this.remoteUsers.sort( sortAuthors );
|
2019-12-10 16:18:24 +00:00
|
|
|
|
2024-11-25 17:15:58 +00:00
|
|
|
this.searchedPrefixes[ input ] = true;
|
2019-12-10 16:18:24 +00:00
|
|
|
} );
|
2020-07-02 14:12:30 +00:00
|
|
|
} else {
|
|
|
|
apiPromise = ve.createDeferred().resolve().promise();
|
2019-12-10 16:18:24 +00:00
|
|
|
}
|
|
|
|
|
2024-04-19 22:07:35 +00:00
|
|
|
return apiPromise.then(
|
2019-12-10 16:18:24 +00:00
|
|
|
// 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.
|
2024-11-25 17:15:58 +00:00
|
|
|
() => this.filterSuggestionsForInput(
|
|
|
|
this.localUsers
|
2020-05-11 18:35:48 +00:00
|
|
|
// Show no remote users if no input provided
|
2024-11-25 17:15:58 +00:00
|
|
|
.concat( input.length > 0 ? this.remoteUsers : [] ),
|
2020-05-06 18:15:19 +00:00
|
|
|
// TODO: Consider showing IP users
|
2020-05-12 11:51:20 +00:00
|
|
|
// * Change link to Special:Contributions/<ip> (localised)
|
2020-05-06 18:15:19 +00:00
|
|
|
// * Let users know that mentioning an IP will not create a notification?
|
|
|
|
// .concat( this.ipUsers )
|
2020-08-16 19:46:52 +00:00
|
|
|
validatedInput
|
2024-04-19 22:07:35 +00:00
|
|
|
)
|
|
|
|
);
|
2019-12-10 16:18:24 +00:00
|
|
|
};
|
|
|
|
|
2022-09-06 14:41:38 +00:00
|
|
|
/**
|
2023-03-01 16:55:56 +00:00
|
|
|
* @inheritdoc
|
2022-09-06 14:41:38 +00:00
|
|
|
*/
|
|
|
|
MWUsernameCompletionAction.prototype.compareSuggestionToInput = function ( suggestion, normalizedInput ) {
|
2024-05-24 12:20:50 +00:00
|
|
|
const normalizedSuggestion = suggestion.username.toLowerCase(),
|
2023-08-17 11:49:15 +00:00
|
|
|
normalizedSearchIndex = normalizedSuggestion + ' ' +
|
2024-04-19 22:07:35 +00:00
|
|
|
suggestion.displayNames
|
|
|
|
.map( ( displayName ) => displayName.toLowerCase() ).join( ' ' );
|
2022-09-06 14:41:38 +00:00
|
|
|
|
|
|
|
return {
|
2023-08-17 11:49:15 +00:00
|
|
|
isMatch: normalizedSearchIndex.indexOf( normalizedInput ) !== -1,
|
2022-09-06 14:41:38 +00:00
|
|
|
isExact: normalizedSuggestion === normalizedInput
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create a suggestion from an input
|
|
|
|
*
|
|
|
|
* @param {string} input User input
|
2024-05-02 09:36:24 +00:00
|
|
|
* @return {any} Suggestion data, string by default
|
2022-09-06 14:41:38 +00:00
|
|
|
*/
|
|
|
|
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 } );
|
|
|
|
};
|
|
|
|
|
2020-07-08 17:20:23 +00:00
|
|
|
MWUsernameCompletionAction.prototype.getHeaderLabel = function ( input, suggestions ) {
|
|
|
|
if ( suggestions === undefined ) {
|
2024-05-24 12:20:50 +00:00
|
|
|
const $query = $( '<span>' ).text( input );
|
2020-07-08 17:20:23 +00:00
|
|
|
return mw.message( 'discussiontools-replywidget-mention-tool-header', $query ).parseDom();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2019-12-10 16:18:24 +00:00
|
|
|
MWUsernameCompletionAction.prototype.insertCompletion = function ( word, range ) {
|
2024-05-24 12:20:50 +00:00
|
|
|
const prefix = mw.msg( 'discussiontools-replywidget-mention-prefix' ),
|
2022-01-19 16:29:10 +00:00
|
|
|
suffix = mw.msg( 'discussiontools-replywidget-mention-suffix' ),
|
2020-06-26 16:23:07 +00:00
|
|
|
title = mw.Title.newFromText( word, mw.config.get( 'wgNamespaceIds' ).user );
|
|
|
|
|
2019-12-10 16:18:24 +00:00
|
|
|
if ( this.surface.getMode() === 'source' ) {
|
|
|
|
// TODO: this should be configurable per-wiki so that e.g. custom templates can be used
|
2022-01-19 16:29:10 +00:00
|
|
|
word = prefix + '[[' + title.getPrefixedText() + '|' + word + ']]' + suffix;
|
2019-12-10 16:18:24 +00:00
|
|
|
return MWUsernameCompletionAction.super.prototype.insertCompletion.call( this, word, range );
|
|
|
|
}
|
2020-06-26 16:23:07 +00:00
|
|
|
|
2024-05-24 12:20:50 +00:00
|
|
|
const fragment = this.surface.getModel().getLinearFragment( range, true );
|
2020-06-26 16:23:07 +00:00
|
|
|
fragment.removeContent().insertContent( [
|
|
|
|
{ type: 'mwPing', attributes: { user: word } },
|
|
|
|
{ type: '/mwPing' }
|
|
|
|
] );
|
2020-05-20 11:28:31 +00:00
|
|
|
|
2020-06-29 13:57:07 +00:00
|
|
|
fragment.collapseToEnd();
|
2020-05-20 11:28:31 +00:00
|
|
|
|
|
|
|
return fragment;
|
2019-12-10 16:18:24 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
MWUsernameCompletionAction.prototype.shouldAbandon = function ( input ) {
|
|
|
|
// TODO: need to consider whether pending loads from server are happening here
|
2023-05-20 06:47:40 +00:00
|
|
|
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
|
|
|
|
);
|
2019-12-10 16:18:24 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
/* Registration */
|
|
|
|
|
|
|
|
ve.ui.actionFactory.register( MWUsernameCompletionAction );
|
|
|
|
|
2024-05-24 12:31:30 +00:00
|
|
|
const openCommand = new ve.ui.Command(
|
2020-09-21 20:13:35 +00:00
|
|
|
'openMWUsernameCompletions', MWUsernameCompletionAction.static.name, 'open',
|
|
|
|
{ supportedSelections: [ 'linear' ] }
|
2020-06-11 14:56:17 +00:00
|
|
|
);
|
2024-05-24 12:31:30 +00:00
|
|
|
const insertAndOpenCommand = new ve.ui.Command(
|
2020-09-21 20:13:35 +00:00
|
|
|
'insertAndOpenMWUsernameCompletions', MWUsernameCompletionAction.static.name, 'insertAndOpen',
|
|
|
|
{ supportedSelections: [ 'linear' ] }
|
2019-12-10 16:18:24 +00:00
|
|
|
);
|
2020-09-21 20:13:35 +00:00
|
|
|
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 );
|
2019-12-10 16:18:24 +00:00
|
|
|
|
|
|
|
module.exports = MWUsernameCompletionAction;
|