mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/DiscussionTools
synced 2024-11-28 10:11:45 +00:00
8e44b43df0
Goal: ----- Finishing the work from Iadb7757debe000025e52770ca51ebcf24ca8ee66 by changing CommentParser::parse() to return a data object, instead of the whole parser. Changes: -------- ThreadItemSet.php: ThreadItemSet.js: * New data class to access the results of parsing a discussion. Most methods and properties are moved from CommentParser with no changes. CommentParser.php: Parser.js: * parse() returns a new ThreadItemSet. * Remove methods moved to ThreadItemSet. * Placeholder headings are generated slightly differently, as we process things in a different order. * Grouping threads and computing IDs/names is no longer lazy. We always needed IDs/names anyway. * computeId() explicitly uses a ThreadItemSet to check the existing IDs when de-duplicating. controller.js: * Move the code for turning some nodes annotated by CommentFormatter into a ThreadItemSet (previously a Parser) from controller#init to ThreadItemSet.static.newFromAnnotatedNodes, and rewrite it to handle assigning parents/replies and recalculating legacy IDs more nicely. * mw.dt.pageThreads is now a ThreadItemSet. Change-Id: I49bfe019aa460651447fd383f73eafa9d7180a92
1017 lines
37 KiB
JavaScript
1017 lines
37 KiB
JavaScript
'use strict';
|
|
|
|
/* global moment */
|
|
var
|
|
$pageContainer, linksController, lastHighlightComment,
|
|
featuresEnabled = mw.config.get( 'wgDiscussionToolsFeaturesEnabled' ) || {},
|
|
seenAutoTopicSubPopup = !!+mw.user.options.get( 'discussiontools-seenautotopicsubpopup' ),
|
|
MemoryStorage = require( './MemoryStorage.js' ),
|
|
storage = new MemoryStorage( mw.storage.session.store ),
|
|
Parser = require( './Parser.js' ),
|
|
ThreadItemSet = require( './ThreadItemSet.js' ),
|
|
CommentItem = require( './CommentItem.js' ),
|
|
CommentDetails = require( './CommentDetails.js' ),
|
|
ReplyLinksController = require( './ReplyLinksController.js' ),
|
|
logger = require( './logger.js' ),
|
|
utils = require( './utils.js' ),
|
|
STATE_UNSUBSCRIBED = 0,
|
|
STATE_SUBSCRIBED = 1,
|
|
STATE_AUTOSUBSCRIBED = 2,
|
|
pageDataCache = {};
|
|
|
|
mw.messages.set( require( './controller/contLangMessages.json' ) );
|
|
|
|
/**
|
|
* Get an MW API instance
|
|
*
|
|
* @return {mw.Api} API instance
|
|
*/
|
|
function getApi() {
|
|
return new mw.Api( {
|
|
parameters: {
|
|
formatversion: 2,
|
|
uselang: mw.config.get( 'wgUserLanguage' )
|
|
}
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Draw a semi-transparent rectangle on the page to highlight the given comment.
|
|
*
|
|
* @param {CommentItem} comment
|
|
* @return {jQuery} Highlight node
|
|
*/
|
|
function highlight( comment ) {
|
|
var padding = 5,
|
|
$highlight = $( '<div>' ).addClass( 'ext-discussiontools-init-highlight' );
|
|
|
|
// We insert the highlight in the DOM near the comment, so that it remains positioned correctly
|
|
// when it shifts (e.g. collapsing the table of contents), and disappears when it is hidden (e.g.
|
|
// opening visual editor).
|
|
var range = comment.getNativeRange();
|
|
// Support: Firefox, IE 11
|
|
// The highlight node must be inserted after the start marker node (data-mw-comment-start), not
|
|
// before, otherwise Node#getBoundingClientRect() returns wrong results.
|
|
range.insertNode( $highlight[ 0 ] );
|
|
|
|
var baseRect = $highlight[ 0 ].getBoundingClientRect();
|
|
var rect = RangeFix.getBoundingClientRect( range );
|
|
// rect may be null if the range is in a detached or hidden node
|
|
if ( rect ) {
|
|
$highlight.css( {
|
|
'margin-top': rect.top - baseRect.top - padding,
|
|
'margin-left': rect.left - baseRect.left - padding,
|
|
width: rect.width + ( padding * 2 ),
|
|
height: rect.height + ( padding * 2 )
|
|
} );
|
|
}
|
|
|
|
return $highlight;
|
|
}
|
|
|
|
/**
|
|
* Get various pieces of page metadata.
|
|
*
|
|
* This method caches responses. If you call it again with the same parameters, you'll get the exact
|
|
* same Promise object, and no API request will be made.
|
|
*
|
|
* @param {string} pageName Page title
|
|
* @param {number} oldId Revision ID
|
|
* @param {boolean} [isNewTopic=false]
|
|
* @return {jQuery.Promise}
|
|
*/
|
|
function getPageData( pageName, oldId, isNewTopic ) {
|
|
var api = getApi();
|
|
|
|
pageDataCache[ pageName ] = pageDataCache[ pageName ] || {};
|
|
if ( pageDataCache[ pageName ][ oldId ] ) {
|
|
return pageDataCache[ pageName ][ oldId ];
|
|
}
|
|
|
|
var lintPromise, transcludedFromPromise;
|
|
if ( oldId && !isNewTopic ) {
|
|
lintPromise = api.get( {
|
|
action: 'query',
|
|
list: 'linterrors',
|
|
lntcategories: 'fostered',
|
|
lntlimit: 1,
|
|
lnttitle: pageName
|
|
} ).then( function ( response ) {
|
|
return OO.getProp( response, 'query', 'linterrors' ) || [];
|
|
} );
|
|
|
|
transcludedFromPromise = api.get( {
|
|
action: 'discussiontoolspageinfo',
|
|
page: pageName,
|
|
oldid: oldId
|
|
} ).then( function ( response ) {
|
|
return OO.getProp( response, 'discussiontoolspageinfo', 'transcludedfrom' ) || {};
|
|
} );
|
|
} else {
|
|
lintPromise = $.Deferred().resolve( [] ).promise();
|
|
transcludedFromPromise = $.Deferred().resolve( {} ).promise();
|
|
}
|
|
|
|
var veMetadataPromise = api.get( {
|
|
action: 'visualeditor',
|
|
paction: 'metadata',
|
|
page: pageName
|
|
} ).then( function ( response ) {
|
|
return OO.getProp( response, 'visualeditor' ) || [];
|
|
} );
|
|
|
|
pageDataCache[ pageName ][ oldId ] = $.when( lintPromise, transcludedFromPromise, veMetadataPromise )
|
|
.then( function ( linterrors, transcludedfrom, metadata ) {
|
|
return {
|
|
linterrors: linterrors,
|
|
transcludedfrom: transcludedfrom,
|
|
metadata: metadata
|
|
};
|
|
}, function () {
|
|
// Clear on failure
|
|
pageDataCache[ pageName ][ oldId ] = null;
|
|
// Let caller handle the error
|
|
return $.Deferred().rejectWith( this, arguments );
|
|
} );
|
|
return pageDataCache[ pageName ][ oldId ];
|
|
}
|
|
|
|
/**
|
|
* Check if a given thread item on a page can be replied to
|
|
*
|
|
* @param {string} pageName Page title
|
|
* @param {number} oldId Revision ID
|
|
* @param {ThreadItem} threadItem Thread item
|
|
* @return {jQuery.Promise} Resolved with a CommentDetails object if the comment appears on the page.
|
|
* Rejects with error data if the comment is transcluded, or there are lint errors on the page.
|
|
*/
|
|
function checkThreadItemOnPage( pageName, oldId, threadItem ) {
|
|
var isNewTopic = threadItem.id === utils.NEW_TOPIC_COMMENT_ID;
|
|
|
|
return getPageData( pageName, oldId, isNewTopic )
|
|
.then( function ( response ) {
|
|
var metadata = response.metadata,
|
|
lintErrors = response.linterrors,
|
|
transcludedFrom = response.transcludedfrom;
|
|
|
|
if ( !isNewTopic ) {
|
|
// First look for data by the thread item's ID. If not found, also look by name.
|
|
// Data by ID may not be found due to differences in headings (e.g. T273413, T275821),
|
|
// or if a thread item's parent changes.
|
|
// Data by name might be combined from two or more thread items, which would only allow us to
|
|
// treat them both as transcluded from unknown source, unless we check ID first.
|
|
var isTranscludedFrom = transcludedFrom[ threadItem.id ];
|
|
if ( isTranscludedFrom === undefined ) {
|
|
isTranscludedFrom = transcludedFrom[ threadItem.name ];
|
|
}
|
|
if ( isTranscludedFrom === undefined ) {
|
|
// The thread item wasn't found when generating the "transcludedfrom" data,
|
|
// so we don't know where the reply should be posted. Just give up.
|
|
return $.Deferred().reject( 'discussiontools-commentid-notfound-transcludedfrom', { errors: [ {
|
|
code: 'discussiontools-commentid-notfound-transcludedfrom',
|
|
html: mw.message( 'discussiontools-error-comment-disappeared' ).parse() +
|
|
'<br>' +
|
|
mw.message( 'discussiontools-error-comment-disappeared-reload' ).parse()
|
|
} ] } ).promise();
|
|
} else if ( isTranscludedFrom ) {
|
|
var mwTitle = isTranscludedFrom === true ? null : mw.Title.newFromText( isTranscludedFrom );
|
|
// If this refers to a template rather than a subpage, we never want to edit it
|
|
var follow = mwTitle && mwTitle.getNamespaceId() !== mw.config.get( 'wgNamespaceIds' ).template;
|
|
|
|
var transcludedErrMsg;
|
|
if ( follow ) {
|
|
transcludedErrMsg = mw.message(
|
|
'discussiontools-error-comment-is-transcluded-title',
|
|
mwTitle.getPrefixedText()
|
|
).parse();
|
|
} else {
|
|
transcludedErrMsg = mw.message(
|
|
'discussiontools-error-comment-is-transcluded',
|
|
// eslint-disable-next-line no-jquery/no-global-selector
|
|
$( '#ca-edit' ).text()
|
|
).parse();
|
|
}
|
|
|
|
return $.Deferred().reject( 'comment-is-transcluded', { errors: [ {
|
|
data: {
|
|
transcludedFrom: isTranscludedFrom,
|
|
follow: follow
|
|
},
|
|
code: 'comment-is-transcluded',
|
|
html: transcludedErrMsg
|
|
} ] } ).promise();
|
|
}
|
|
|
|
if ( lintErrors.length ) {
|
|
// We currently only request the first error
|
|
var lintType = lintErrors[ 0 ].category;
|
|
|
|
return $.Deferred().reject( 'lint', { errors: [ {
|
|
code: 'lint',
|
|
html: mw.message( 'discussiontools-error-lint',
|
|
'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Lint_errors/' + lintType,
|
|
'https://www.mediawiki.org/wiki/Special:MyLanguage/Help_talk:Lint_errors/' + lintType,
|
|
mw.util.getUrl( pageName, { action: 'edit', lintid: lintErrors[ 0 ].lintId } ) ).parse()
|
|
} ] } ).promise();
|
|
}
|
|
}
|
|
|
|
if ( !metadata.canEdit ) {
|
|
return $.Deferred().reject( 'permissions-error', { errors: [ {
|
|
code: 'permissions-error',
|
|
html: metadata.notices[ 'permissions-error' ]
|
|
} ] } ).promise();
|
|
}
|
|
|
|
return new CommentDetails( pageName, oldId, metadata.notices );
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Get a promise which resolves with editor checkbox data
|
|
*
|
|
* @param {string} pageName Page title
|
|
* @param {number} oldId Revision ID
|
|
* @return {jQuery.Promise} See ve.init.mw.ArticleTargetLoader#createCheckboxFields
|
|
*/
|
|
function getCheckboxesPromise( pageName, oldId ) {
|
|
return getPageData(
|
|
pageName,
|
|
oldId
|
|
).then( function ( pageData ) {
|
|
var data = pageData.metadata,
|
|
checkboxesDef = {};
|
|
|
|
mw.messages.set( data.checkboxesMessages );
|
|
|
|
// Only show the watch checkbox for now
|
|
if ( 'wpWatchthis' in data.checkboxesDef ) {
|
|
checkboxesDef.wpWatchthis = data.checkboxesDef.wpWatchthis;
|
|
// Override the label with a more verbose one to distinguish this from topic subscriptions (T290712)
|
|
checkboxesDef.wpWatchthis[ 'label-message' ] = 'discussiontools-replywidget-watchthis';
|
|
}
|
|
return mw.loader.using( 'ext.visualEditor.targetLoader' ).then( function () {
|
|
return mw.libs.ve.targetLoader.createCheckboxFields( checkboxesDef );
|
|
} );
|
|
// TODO: createCheckboxField doesn't make links in the label open in a new
|
|
// window as that method currently lives in ve.utils
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Update a subscribe button
|
|
*
|
|
* @param {HTMLElement} element Subscribe button
|
|
* @param {number|null} state State constant (STATE_UNSUBSCRIBED, STATE_SUBSCRIBED or STATE_AUTOSUBSCRIBED)
|
|
*/
|
|
function updateSubscribeButton( element, state ) {
|
|
if ( state !== null ) {
|
|
element.setAttribute( 'data-mw-subscribed', String( state ) );
|
|
}
|
|
if ( state ) {
|
|
element.textContent = mw.msg( 'discussiontools-topicsubscription-button-unsubscribe' );
|
|
element.setAttribute( 'title', mw.msg( 'discussiontools-topicsubscription-button-unsubscribe-tooltip' ) );
|
|
} else {
|
|
element.textContent = mw.msg( 'discussiontools-topicsubscription-button-subscribe' );
|
|
element.setAttribute( 'title', mw.msg( 'discussiontools-topicsubscription-button-subscribe-tooltip' ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize topic subscriptions feature
|
|
*
|
|
* @param {jQuery} $container Page container
|
|
*/
|
|
function initTopicSubscriptions( $container ) {
|
|
$container.find( '.ext-discussiontools-init-section-subscribe-link' ).on( 'click keypress', function ( e ) {
|
|
if ( e.type === 'keypress' && e.which !== OO.ui.Keys.ENTER && e.which !== OO.ui.Keys.SPACE ) {
|
|
// Only handle keypresses on the "Enter" or "Space" keys
|
|
return;
|
|
}
|
|
if ( e.type === 'click' && !utils.isUnmodifiedLeftClick( e ) ) {
|
|
// Only handle unmodified left clicks
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
var commentName = this.getAttribute( 'data-mw-comment-name' );
|
|
|
|
if ( !commentName ) {
|
|
// This should never happen
|
|
return;
|
|
}
|
|
|
|
var element = this,
|
|
api = getApi(),
|
|
subscribedState = element.hasAttribute( 'data-mw-subscribed' ) ?
|
|
Number( element.getAttribute( 'data-mw-subscribed' ) ) : null,
|
|
heading = $( this ).closest( '.ext-discussiontools-init-section' )[ 0 ],
|
|
section = utils.getHeadlineNodeAndOffset( heading ).node.id,
|
|
title = mw.config.get( 'wgRelevantPageName' ) + '#' + section;
|
|
|
|
$( element ).addClass( 'ext-discussiontools-init-section-subscribe-link-pending' );
|
|
|
|
api.postWithToken( 'csrf', {
|
|
action: 'discussiontoolssubscribe',
|
|
page: title,
|
|
commentname: commentName,
|
|
subscribe: !subscribedState
|
|
} ).then( function ( response ) {
|
|
return OO.getProp( response, 'discussiontoolssubscribe' ) || {};
|
|
} ).then( function ( result ) {
|
|
$( element ).removeClass( 'ext-discussiontools-init-section-subscribe-link-pending' );
|
|
updateSubscribeButton( element, result.subscribe ? STATE_SUBSCRIBED : STATE_UNSUBSCRIBED );
|
|
mw.notify(
|
|
mw.msg(
|
|
result.subscribe ?
|
|
'discussiontools-topicsubscription-notify-subscribed-body' :
|
|
'discussiontools-topicsubscription-notify-unsubscribed-body'
|
|
),
|
|
{
|
|
title: mw.msg(
|
|
result.subscribe ?
|
|
'discussiontools-topicsubscription-notify-subscribed-title' :
|
|
'discussiontools-topicsubscription-notify-unsubscribed-title'
|
|
)
|
|
}
|
|
);
|
|
}, function ( code, data ) {
|
|
mw.notify( api.getErrorMessage( data ), { type: 'error' } );
|
|
$( element ).removeClass( 'ext-discussiontools-init-section-subscribe-link-pending' );
|
|
} );
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Show the first time popup for auto topic subscriptions, if required
|
|
*/
|
|
function maybeShowFirstTimeAutoTopicSubPopup() {
|
|
if ( !lastHighlightComment || seenAutoTopicSubPopup ) {
|
|
return;
|
|
}
|
|
|
|
seenAutoTopicSubPopup = true;
|
|
mw.user.options.set( 'discussiontools-seenautotopicsubpopup', '1' );
|
|
getApi().saveOption( 'discussiontools-seenautotopicsubpopup', '1' );
|
|
|
|
var $popupContent, popup;
|
|
|
|
function close() {
|
|
popup.$element.removeClass( 'ext-discussiontools-autotopicsubpopup-fadein' );
|
|
setTimeout( function () {
|
|
popup.$element.detach();
|
|
}, 1000 );
|
|
}
|
|
|
|
$popupContent = $( '<div>' )
|
|
.append(
|
|
$( '<strong>' )
|
|
.addClass( 'ext-discussiontools-autotopicsubpopup-title' )
|
|
.text( mw.msg( 'discussiontools-autotopicsubpopup-title' ) ),
|
|
$( '<div>' )
|
|
.addClass( 'ext-discussiontools-autotopicsubpopup-image' ),
|
|
$( '<div>' )
|
|
.addClass( 'ext-discussiontools-autotopicsubpopup-body' )
|
|
.text( mw.msg( 'discussiontools-autotopicsubpopup-body' ) ),
|
|
$( '<div>' )
|
|
.addClass( 'ext-discussiontools-autotopicsubpopup-actions' )
|
|
.append( new OO.ui.ButtonWidget( {
|
|
label: mw.msg( 'discussiontools-autotopicsubpopup-dismiss' ),
|
|
flags: [ 'primary', 'progressive' ]
|
|
} ).on( 'click', close ).$element )
|
|
.append( new OO.ui.ButtonWidget( {
|
|
label: mw.msg( 'discussiontools-autotopicsubpopup-preferences' ),
|
|
href: mw.util.getUrl( 'Special:Preferences#mw-prefsection-editing-discussion' ),
|
|
flags: [ 'progressive' ],
|
|
framed: false
|
|
} ).$element )
|
|
);
|
|
|
|
popup = new OO.ui.PopupWidget( {
|
|
// Styles and dimensions
|
|
width: '',
|
|
height: '',
|
|
anchor: false,
|
|
autoClose: false,
|
|
head: false,
|
|
padded: false,
|
|
classes: [ 'ext-discussiontools-autotopicsubpopup' ],
|
|
hideWhenOutOfView: false,
|
|
// Content
|
|
$content: $popupContent.contents()
|
|
} );
|
|
|
|
// Like in highlight()
|
|
lastHighlightComment.getNativeRange().insertNode( popup.$element[ 0 ] );
|
|
// Pull it outside of headings to avoid silly fonts
|
|
if ( popup.$element.closest( 'h1, h2, h3, h4, h5, h6' ).length ) {
|
|
popup.$element.closest( 'h1, h2, h3, h4, h5, h6' ).after( popup.$element );
|
|
}
|
|
|
|
// Disable positioning, the popup is positioned in CSS, above the highlight
|
|
popup.toggle( true ).toggleClipping( false ).togglePositioning( false );
|
|
|
|
// If the page is very short, there might not be enough space above the highlight,
|
|
// causing the popup to overlap the skin navigation or even be off-screen.
|
|
// Position it on top of the highlight in that case...
|
|
// eslint-disable-next-line no-jquery/no-global-selector
|
|
if ( popup.$popup[ 0 ].getBoundingClientRect().top < $( '.mw-body' )[ 0 ].getBoundingClientRect().top ) {
|
|
popup.$popup.addClass( 'ext-discussiontools-autotopicsubpopup-overlap' );
|
|
}
|
|
|
|
// Scroll into view, leave some space above to avoid overlapping .postedit-container
|
|
OO.ui.Element.static.scrollIntoView(
|
|
popup.$popup[ 0 ],
|
|
{
|
|
padding: {
|
|
// Add padding to avoid overlapping the post-edit notification (above on desktop, below on mobile)
|
|
top: OO.ui.isMobile() ? 10 : 60,
|
|
bottom: OO.ui.isMobile() ? 85 : 10
|
|
},
|
|
// Specify scrollContainer for compatibility with MobileFrontend.
|
|
// Apparently it makes `<dd>` elements scrollable and OOUI tried to scroll them instead of body.
|
|
scrollContainer: OO.ui.Element.static.getRootScrollableElement( popup.$popup[ 0 ] )
|
|
}
|
|
);
|
|
|
|
popup.$element.addClass( 'ext-discussiontools-autotopicsubpopup-fadein' );
|
|
}
|
|
|
|
/**
|
|
* Update the subscription state of various topics
|
|
*
|
|
* @param {jQuery} $container Page container
|
|
* @param {Object.<string, HeadingItem>} headingsToUpdate Headings of topics where subscription state has changed
|
|
*/
|
|
function updateSubscriptionStates( $container, headingsToUpdate ) {
|
|
// This method is called when we recently edited this page, and auto-subscriptions might have been
|
|
// added for some topics. It updates the [subscribe] buttons to reflect the new subscriptions.
|
|
|
|
var $links = $container.find( '.ext-discussiontools-init-section-subscribe-link' );
|
|
var linksByName = {};
|
|
$links.each( function () {
|
|
linksByName[ this.getAttribute( 'data-mw-comment-name' ) ] = this;
|
|
} );
|
|
|
|
// If the topic is already marked as auto-subscribed, there's nothing to do.
|
|
// (Except maybe show the first-time popup.)
|
|
// If the topic is marked as having never been subscribed, check if they are auto-subscribed now.
|
|
var topicsToCheck = [];
|
|
var pending = [];
|
|
for ( var headingName in headingsToUpdate ) {
|
|
var el = linksByName[ headingName ];
|
|
var subscribedState = el.hasAttribute( 'data-mw-subscribed' ) ?
|
|
Number( el.getAttribute( 'data-mw-subscribed' ) ) : null;
|
|
|
|
if ( subscribedState === STATE_AUTOSUBSCRIBED ) {
|
|
maybeShowFirstTimeAutoTopicSubPopup();
|
|
} else if ( subscribedState === null || subscribedState === STATE_UNSUBSCRIBED ) {
|
|
topicsToCheck.push( headingName );
|
|
pending.push( el );
|
|
}
|
|
}
|
|
$( pending ).addClass( 'ext-discussiontools-init-section-subscribe-link-pending' );
|
|
|
|
if ( !topicsToCheck.length ) {
|
|
return;
|
|
}
|
|
|
|
var api = getApi();
|
|
api.get( {
|
|
action: 'discussiontoolsgetsubscriptions',
|
|
commentname: topicsToCheck
|
|
} ).then( function ( response ) {
|
|
if ( $.isEmptyObject( response.subscriptions ) ) {
|
|
// If none of the topics has an auto-subscription yet, wait a moment and check again.
|
|
// updateSubscriptionStates() method is only called if we're really expecting one to be there.
|
|
// (There are certainly neater ways to implement this, involving push notifications or at
|
|
// least long-polling or something. But this is the simplest one!)
|
|
var wait = $.Deferred();
|
|
setTimeout( wait.resolve, 5000 );
|
|
return wait.then( function () {
|
|
return api.get( {
|
|
action: 'discussiontoolsgetsubscriptions',
|
|
commentname: topicsToCheck
|
|
} );
|
|
} );
|
|
}
|
|
return response;
|
|
} ).then( function ( response ) {
|
|
// Update state of each topic for which there is a subscription
|
|
for ( var subItemName in response.subscriptions ) {
|
|
var state = response.subscriptions[ subItemName ];
|
|
updateSubscribeButton( linksByName[ subItemName ], state );
|
|
if ( state === STATE_AUTOSUBSCRIBED ) {
|
|
maybeShowFirstTimeAutoTopicSubPopup();
|
|
}
|
|
}
|
|
$( pending ).removeClass( 'ext-discussiontools-init-section-subscribe-link-pending' );
|
|
}, function () {
|
|
$( pending ).removeClass( 'ext-discussiontools-init-section-subscribe-link-pending' );
|
|
} );
|
|
}
|
|
|
|
var $highlightedTarget = null;
|
|
/**
|
|
* Highlight the comment on the page associated with the URL hash
|
|
*
|
|
* @param {ThreadItemSet} threadItemSet
|
|
* @param {boolean} [noScroll] Don't scroll to the topmost highlighted comment, e.g. on popstate
|
|
*/
|
|
function highlightTargetComment( threadItemSet, noScroll ) {
|
|
// Delay with setTimeout() because "the Document's target element" (corresponding to the :target
|
|
// selector in CSS) is not yet updated to match the URL when handling a 'popstate' event.
|
|
setTimeout( function () {
|
|
if ( $highlightedTarget ) {
|
|
$highlightedTarget.remove();
|
|
$highlightedTarget = null;
|
|
}
|
|
// eslint-disable-next-line no-jquery/no-global-selector
|
|
var targetElement = $( ':target' )[ 0 ];
|
|
|
|
var uri;
|
|
try {
|
|
uri = new mw.Uri( location.href );
|
|
} catch ( err ) {
|
|
// T106244: URL encoded values using fallback 8-bit encoding (invalid UTF-8) cause mediawiki.Uri to crash
|
|
uri = null;
|
|
}
|
|
var targetIds = uri && uri.query.dtnewcomments && uri.query.dtnewcomments.split( '|' );
|
|
if ( targetElement && targetElement.hasAttribute( 'data-mw-comment-start' ) ) {
|
|
var comment = threadItemSet.findCommentById( targetElement.getAttribute( 'id' ) );
|
|
$highlightedTarget = highlight( comment );
|
|
$highlightedTarget.addClass( 'ext-discussiontools-init-targetcomment' );
|
|
$highlightedTarget.addClass( 'ext-discussiontools-init-highlight-fadein' );
|
|
} else if ( targetIds ) {
|
|
var comments = targetIds.map( function ( id ) {
|
|
return threadItemSet.findCommentById( id );
|
|
} ).filter( function ( cmt ) {
|
|
return !!cmt;
|
|
} );
|
|
if ( comments.length === 0 ) {
|
|
return;
|
|
}
|
|
|
|
var highlights = comments.map( function ( cmt ) {
|
|
return highlight( cmt )[ 0 ];
|
|
} );
|
|
$highlightedTarget = $( highlights );
|
|
$highlightedTarget.addClass( 'ext-discussiontools-init-targetcomment' );
|
|
$highlightedTarget.addClass( 'ext-discussiontools-init-highlight-fadein' );
|
|
|
|
if ( !noScroll ) {
|
|
var topmostComment = 0;
|
|
for ( var i = 1; i < comments.length; i++ ) {
|
|
if ( highlights[ i ].getBoundingClientRect().top < highlights[ topmostComment ].getBoundingClientRect().top ) {
|
|
topmostComment = i;
|
|
}
|
|
}
|
|
document.getElementById( comments[ topmostComment ].id ).scrollIntoView();
|
|
}
|
|
}
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Clear the highlighting of the comment in the URL hash
|
|
*
|
|
* @param {ThreadItemSet} threadItemSet
|
|
*/
|
|
function clearHighlightTargetComment( threadItemSet ) {
|
|
// eslint-disable-next-line no-jquery/no-global-selector
|
|
var targetElement = $( ':target' )[ 0 ];
|
|
if ( targetElement && targetElement.hasAttribute( 'data-mw-comment-start' ) ) {
|
|
// Clear the hash from the URL, triggering the 'hashchange' event and updating the :target
|
|
// selector (so that our code to clear our highlight works), but without scrolling anywhere.
|
|
// This is tricky because:
|
|
// * Using history.pushState() does not trigger 'hashchange' or update the :target selector.
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/History/pushState#description
|
|
// https://github.com/whatwg/html/issues/639
|
|
// * Using window.location.hash does, but it also scrolls to the target, which is the top of the
|
|
// page for the empty hash.
|
|
// Instead, we first use window.location.hash to navigate to a *different* hash (whose target
|
|
// doesn't exist on the page, hopefully), and then use history.pushState() to clear it.
|
|
window.location.hash += '-DoesNotExist-DiscussionToolsHack';
|
|
history.replaceState( null, document.title, window.location.href.replace( /#.*$/, '' ) );
|
|
} else if ( window.location.search.match( /(^\?|&)dtnewcomments=/ ) ) {
|
|
history.pushState( null, document.title,
|
|
window.location.search.replace( /(^\?|&)dtnewcomments=[^&]+/, '' ) + window.location.hash );
|
|
highlightTargetComment( threadItemSet );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize Discussion Tools features
|
|
*
|
|
* @param {jQuery} $container Page container
|
|
* @param {Object<string,Mixed>} [state] Page state data object
|
|
* @param {string} [state.repliedTo] The comment ID that was just replied to
|
|
*/
|
|
function init( $container, state ) {
|
|
var
|
|
activeCommentId = null,
|
|
activeController = null,
|
|
// Loads later to avoid circular dependency
|
|
CommentController = require( './CommentController.js' ),
|
|
NewTopicController = require( './NewTopicController.js' );
|
|
|
|
// Lazy-load postEdit module, may be required later (on desktop)
|
|
mw.loader.using( 'mediawiki.action.view.postEdit' );
|
|
|
|
$pageContainer = $container;
|
|
linksController = new ReplyLinksController( $pageContainer );
|
|
var parser = new Parser( require( './parser/data.json' ) );
|
|
|
|
var commentNodes = $pageContainer[ 0 ].querySelectorAll( '[data-mw-comment]' );
|
|
var result = ThreadItemSet.static.newFromAnnotatedNodes( commentNodes, parser );
|
|
|
|
if ( featuresEnabled.topicsubscription ) {
|
|
initTopicSubscriptions( $container );
|
|
}
|
|
|
|
/**
|
|
* Setup comment controllers for each comment, and the new topic controller
|
|
*
|
|
* @param {string} commentId Comment ID, or NEW_TOPIC_COMMENT_ID constant
|
|
* @param {jQuery} $link Add section link for new topic controller
|
|
* @param {string} [mode] Optionally force a mode, 'visual' or 'source'
|
|
* @param {boolean} [hideErrors] Suppress errors, e.g. when restoring auto-save
|
|
*/
|
|
function setupController( commentId, $link, mode, hideErrors ) {
|
|
var commentController, $addSectionLink;
|
|
if ( commentId === utils.NEW_TOPIC_COMMENT_ID ) {
|
|
// eslint-disable-next-line no-jquery/no-global-selector
|
|
$addSectionLink = $( '#ca-addsection a' );
|
|
// When opening new topic tool using any link, always activate the link in page tabs too
|
|
$link = $link.add( $addSectionLink );
|
|
commentController = new NewTopicController( $pageContainer, result );
|
|
} else {
|
|
commentController = new CommentController( $pageContainer, result.findCommentById( commentId ), result );
|
|
}
|
|
|
|
activeCommentId = commentId;
|
|
activeController = commentController;
|
|
linksController.setActiveLink( $link );
|
|
|
|
commentController.on( 'teardown', function ( teardownMode ) {
|
|
activeCommentId = null;
|
|
activeController = null;
|
|
linksController.clearActiveLink();
|
|
|
|
if ( teardownMode === 'abandoned' ) {
|
|
linksController.focusLink( $link );
|
|
}
|
|
} );
|
|
|
|
commentController.setup( mode, hideErrors );
|
|
}
|
|
|
|
// Hook up each link to open a reply widget
|
|
//
|
|
// TODO: Allow users to use multiple reply widgets simultaneously.
|
|
// Currently submitting a reply from one widget would also destroy the other ones.
|
|
linksController.on( 'link-click', function ( commentId, $link ) {
|
|
// If the reply widget is already open, activate it.
|
|
// Reply links are also made unclickable using 'pointer-events' in CSS, but that doesn't happen
|
|
// for new section links, because we don't have a good way of visually disabling them.
|
|
// (And it also doesn't work on IE 11.)
|
|
if ( activeCommentId === commentId ) {
|
|
activeController.showAndFocus();
|
|
return;
|
|
}
|
|
|
|
// If this is a new topic link, and a reply widget is open, attempt to close it first.
|
|
var teardownPromise;
|
|
if ( activeController && commentId === utils.NEW_TOPIC_COMMENT_ID ) {
|
|
teardownPromise = activeController.replyWidget.tryTeardown();
|
|
} else {
|
|
teardownPromise = $.Deferred().resolve();
|
|
}
|
|
|
|
teardownPromise.then( function () {
|
|
// If another reply widget is open (or opening), do nothing.
|
|
if ( activeController ) {
|
|
return;
|
|
}
|
|
setupController( commentId, $link );
|
|
} );
|
|
} );
|
|
|
|
// Restore autosave
|
|
( function () {
|
|
var mode, $link;
|
|
for ( var i = 0; i < result.threadItems.length; i++ ) {
|
|
var comment = result.threadItems[ i ];
|
|
if ( storage.get( 'reply/' + comment.id + '/saveable' ) ) {
|
|
mode = storage.get( 'reply/' + comment.id + '/mode' );
|
|
$link = $( commentNodes[ i ] );
|
|
setupController( comment.id, $link, mode, true );
|
|
break;
|
|
}
|
|
}
|
|
if ( storage.get( 'reply/' + utils.NEW_TOPIC_COMMENT_ID + '/saveable' ) ) {
|
|
mode = storage.get( 'reply/' + utils.NEW_TOPIC_COMMENT_ID + '/mode' );
|
|
setupController( utils.NEW_TOPIC_COMMENT_ID, $( [] ), mode, true );
|
|
} else if ( mw.config.get( 'wgDiscussionToolsStartNewTopicTool' ) ) {
|
|
setupController( utils.NEW_TOPIC_COMMENT_ID, $( [] ) );
|
|
}
|
|
}() );
|
|
|
|
// For debugging (now unused in the code)
|
|
mw.dt.pageThreads = result;
|
|
|
|
var promise = OO.ui.isMobile() && mw.loader.getState( 'mobile.init' ) ?
|
|
mw.loader.using( 'mobile.init' ) :
|
|
$.Deferred().resolve().promise();
|
|
|
|
promise.then( function () {
|
|
var $highlight;
|
|
if ( state.repliedTo === utils.NEW_TOPIC_COMMENT_ID ) {
|
|
// Highlight the last comment on the page
|
|
var lastComment = result.threadItems[ result.threadItems.length - 1 ];
|
|
$highlight = highlight( lastComment );
|
|
lastHighlightComment = lastComment;
|
|
|
|
// If it's the only comment under its heading, highlight the heading too.
|
|
// (It might not be if the new discussion topic was posted without a title: T272666.)
|
|
if (
|
|
lastComment.parent &&
|
|
lastComment.parent.type === 'heading' &&
|
|
lastComment.parent.replies.length === 1
|
|
) {
|
|
$highlight = $highlight.add( highlight( lastComment.parent ) );
|
|
lastHighlightComment = lastComment.parent;
|
|
}
|
|
|
|
mw.hook( 'postEdit' ).fire( {
|
|
message: mw.msg( 'discussiontools-postedit-confirmation-topicadded', mw.user )
|
|
} );
|
|
|
|
} else if ( state.repliedTo ) {
|
|
// Find the comment we replied to, then highlight the last reply
|
|
var repliedToComment = result.threadItemsById[ state.repliedTo ];
|
|
$highlight = highlight( repliedToComment.replies[ repliedToComment.replies.length - 1 ] );
|
|
lastHighlightComment = repliedToComment.replies[ repliedToComment.replies.length - 1 ];
|
|
|
|
if ( OO.ui.isMobile() ) {
|
|
mw.notify( mw.msg( 'discussiontools-postedit-confirmation-published', mw.user ) );
|
|
} else {
|
|
// postEdit is currently desktop only
|
|
mw.hook( 'postEdit' ).fire( {
|
|
message: mw.msg( 'discussiontools-postedit-confirmation-published', mw.user )
|
|
} );
|
|
}
|
|
}
|
|
|
|
if ( $highlight ) {
|
|
$highlight.addClass( 'ext-discussiontools-init-publishedcomment' );
|
|
|
|
// Show a highlight with the same timing as the post-edit message (mediawiki.action.view.postEdit):
|
|
// show for 3000ms, fade out for 250ms (animation duration is defined in CSS).
|
|
OO.ui.Element.static.scrollIntoView(
|
|
$highlight[ 0 ],
|
|
{
|
|
padding: {
|
|
// Add padding to avoid overlapping the post-edit notification (above on desktop, below on mobile)
|
|
top: OO.ui.isMobile() ? 10 : 60,
|
|
bottom: OO.ui.isMobile() ? 85 : 10
|
|
},
|
|
// Specify scrollContainer for compatibility with MobileFrontend.
|
|
// Apparently it makes `<dd>` elements scrollable and OOUI tried to scroll them instead of body.
|
|
scrollContainer: OO.ui.Element.static.getRootScrollableElement( $highlight[ 0 ] )
|
|
}
|
|
).then( function () {
|
|
$highlight.addClass( 'ext-discussiontools-init-highlight-fadein' );
|
|
setTimeout( function () {
|
|
$highlight.addClass( 'ext-discussiontools-init-highlight-fadeout' );
|
|
setTimeout( function () {
|
|
// Remove the node when no longer needed, because it's using CSS 'mix-blend-mode', which
|
|
// affects the text rendering of the whole page, disabling subpixel antialiasing on Windows
|
|
$highlight.remove();
|
|
}, 250 );
|
|
}, 3000 );
|
|
} );
|
|
}
|
|
|
|
// Check topic subscription states if the user has automatic subscriptions enabled
|
|
// and has recently edited this page.
|
|
if ( featuresEnabled.autotopicsub && mw.user.options.get( 'discussiontools-autotopicsub' ) ) {
|
|
var recentComments = [];
|
|
var headingsToUpdate = {};
|
|
if ( state.repliedTo ) {
|
|
// Edited by using the reply tool or new topic tool. Only check the edited topic.
|
|
if ( state.repliedTo === utils.NEW_TOPIC_COMMENT_ID ) {
|
|
recentComments.push( result.threadItems[ result.threadItems.length - 1 ] );
|
|
} else {
|
|
recentComments.push( result.threadItemsById[ state.repliedTo ] );
|
|
}
|
|
} else if ( mw.config.get( 'wgPostEdit' ) ) {
|
|
// Edited by using wikitext editor. Check topics with their own comments within last minute.
|
|
for ( var i = 0; i < result.threadItems.length; i++ ) {
|
|
if (
|
|
result.threadItems[ i ] instanceof CommentItem &&
|
|
result.threadItems[ i ].author === mw.user.getName() &&
|
|
result.threadItems[ i ].timestamp.isSameOrAfter( moment().subtract( 1, 'minute' ), 'minute' )
|
|
) {
|
|
recentComments.push( result.threadItems[ i ] );
|
|
}
|
|
}
|
|
}
|
|
recentComments.forEach( function ( recentComment ) {
|
|
var headingItem = recentComment.getSubscribableHeading();
|
|
if ( headingItem ) {
|
|
// Use names as object keys to deduplicate if there are multiple comments in a topic.
|
|
headingsToUpdate[ headingItem.name ] = headingItem;
|
|
}
|
|
} );
|
|
updateSubscriptionStates( $container, headingsToUpdate );
|
|
}
|
|
} );
|
|
|
|
// Preload page metadata.
|
|
// TODO: Isn't this too early to load it? We will only need it if the user tries replying...
|
|
getPageData(
|
|
mw.config.get( 'wgRelevantPageName' ),
|
|
mw.config.get( 'wgCurRevisionId' )
|
|
);
|
|
|
|
$( window ).on( 'popstate', function () {
|
|
highlightTargetComment( result, true );
|
|
} );
|
|
// eslint-disable-next-line no-jquery/no-global-selector
|
|
$( 'body' ).on( 'click', function ( e ) {
|
|
if ( utils.isUnmodifiedLeftClick( e ) ) {
|
|
clearHighlightTargetComment( result );
|
|
}
|
|
} );
|
|
highlightTargetComment( result );
|
|
}
|
|
|
|
/**
|
|
* Update the contents of the page with the data from an action=parse API response.
|
|
*
|
|
* @param {jQuery} $container Page container
|
|
* @param {Object} data Data from action=parse API
|
|
*/
|
|
function updatePageContents( $container, data ) {
|
|
var $content = $( $.parseHTML( data.parse.text ) );
|
|
$container.find( '.mw-parser-output' ).replaceWith( $content );
|
|
mw.config.set( data.parse.jsconfigvars );
|
|
mw.loader.load( data.parse.modulestyles );
|
|
mw.loader.load( data.parse.modules );
|
|
|
|
mw.config.set( {
|
|
wgCurRevisionId: data.parse.revid,
|
|
wgRevisionId: data.parse.revid
|
|
} );
|
|
|
|
// TODO update categories, displaytitle, lastmodified
|
|
// We may not be able to use prop=displaytitle without making changes in the action=parse API,
|
|
// VE API has some confusing code that changes the HTML escaping on it before returning???
|
|
|
|
// We need our init code to run after everyone else's handlers for this hook,
|
|
// so that all changes to the page layout have been completed (e.g. collapsible elements),
|
|
// and we can measure things and display the highlight in the right place.
|
|
mw.hook( 'wikipage.content' ).remove( mw.dt.init );
|
|
mw.hook( 'wikipage.content' ).fire( $container );
|
|
// The hooks have "memory" so calling add() after fire() actually fires the handler,
|
|
// and calling add() before fire() would actually fire it twice.
|
|
mw.hook( 'wikipage.content' ).add( mw.dt.init );
|
|
}
|
|
|
|
/**
|
|
* Load the latest revision of the page and display its contents.
|
|
*
|
|
* @return {jQuery.Promise} Promise which resolves when the refresh is complete
|
|
*/
|
|
function refreshPageContents() {
|
|
return getApi().get( {
|
|
action: 'parse',
|
|
// HACK: 'useskin' triggers a different code path that runs our OutputPageBeforeHTML hook,
|
|
// adding our reply links in the HTML (T266195)
|
|
useskin: mw.config.get( 'skin' ),
|
|
uselang: mw.config.get( 'wgUserLanguage' ),
|
|
// HACK: Always display reply links afterwards, ignoring preferences etc., in case this was
|
|
// a page view with reply links forced with ?dtenable=1 or otherwise
|
|
dtenable: '1',
|
|
prop: [ 'text', 'modules', 'jsconfigvars', 'revid' ],
|
|
page: mw.config.get( 'wgRelevantPageName' )
|
|
} ).then( function ( parseResp ) {
|
|
updatePageContents( $pageContainer, parseResp );
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Update the page after a comment is published/saved
|
|
*
|
|
* @param {Object} data Edit API response data
|
|
* @param {ThreadItem} threadItem Parent thread item
|
|
* @param {string} pageName Page title
|
|
* @param {mw.dt.ReplyWidget} replyWidget ReplyWidget
|
|
*/
|
|
function update( data, threadItem, pageName, replyWidget ) {
|
|
// We posted a new comment, clear the cache, because wgCurRevisionId will not change if we posted
|
|
// to a transcluded page (T266275)
|
|
pageDataCache[ mw.config.get( 'wgRelevantPageName' ) ][ mw.config.get( 'wgCurRevisionId' ) ] = null;
|
|
|
|
var pageExists = !!mw.config.get( 'wgRelevantArticleId' );
|
|
if ( !pageExists ) {
|
|
// The page didn't exist before this update, so reload it. We'd handle
|
|
// setting up the content just fine (assuming there's a
|
|
// mw-parser-output), but fixing up the UI tabs/behavior is outside
|
|
// our scope.
|
|
replyWidget.unbindBeforeUnloadHandler();
|
|
replyWidget.clearStorage();
|
|
replyWidget.setPending( true );
|
|
window.location = mw.util.getUrl( pageName, { dtrepliedto: threadItem.id } );
|
|
return;
|
|
}
|
|
|
|
replyWidget.teardown();
|
|
linksController.teardown();
|
|
linksController = null;
|
|
// TODO: Tell controller to teardown all other open widgets
|
|
|
|
if ( OO.ui.isMobile() ) {
|
|
// MobileFrontend does not use the 'wikipage.content' hook, and its interface will not
|
|
// re-initialize properly (e.g. page sections won't be collapsible). Reload the whole page.
|
|
window.location = mw.util.getUrl( pageName, { dtrepliedto: threadItem.id } );
|
|
return;
|
|
}
|
|
|
|
// Highlight the new reply after re-initializing
|
|
mw.dt.initState.repliedTo = threadItem.id;
|
|
|
|
// Update page state
|
|
var pageUpdated = $.Deferred();
|
|
if ( pageName === mw.config.get( 'wgRelevantPageName' ) ) {
|
|
// We can use the result from the VisualEditor API
|
|
updatePageContents( $pageContainer, {
|
|
parse: {
|
|
text: data.content,
|
|
jsconfigvars: data.jsconfigvars,
|
|
revid: data.newrevid,
|
|
// Note: VE API merges 'modules' and 'modulestyles'
|
|
modules: data.modules,
|
|
modulestyles: []
|
|
}
|
|
} );
|
|
|
|
mw.config.set( {
|
|
wgCurRevisionId: data.newrevid,
|
|
wgRevisionId: data.newrevid
|
|
} );
|
|
|
|
pageUpdated.resolve();
|
|
|
|
} else {
|
|
// We saved to another page, we must purge and then fetch the current page
|
|
var api = getApi();
|
|
api.post( {
|
|
action: 'purge',
|
|
titles: mw.config.get( 'wgRelevantPageName' )
|
|
} ).then( function () {
|
|
return refreshPageContents();
|
|
} ).then( function () {
|
|
pageUpdated.resolve();
|
|
} ).catch( function () {
|
|
// We saved the reply, but couldn't purge or fetch the updated page. Seems difficult to
|
|
// explain this problem. Redirect to the page where the user can at least see their reply…
|
|
window.location = mw.util.getUrl( pageName, { dtrepliedto: threadItem.id } );
|
|
} );
|
|
}
|
|
|
|
// User logged in if module loaded.
|
|
if ( mw.loader.getState( 'mediawiki.page.watch.ajax' ) === 'ready' ) {
|
|
var watch = require( 'mediawiki.page.watch.ajax' );
|
|
|
|
watch.updateWatchLink(
|
|
mw.Title.newFromText( pageName ),
|
|
data.watched ? 'unwatch' : 'watch',
|
|
'idle',
|
|
data.watchlistexpiry
|
|
);
|
|
}
|
|
|
|
pageUpdated.then( function () {
|
|
logger( {
|
|
action: 'saveSuccess',
|
|
timing: mw.now() - replyWidget.saveInitiated,
|
|
// eslint-disable-next-line camelcase
|
|
revision_id: data.newrevid
|
|
} );
|
|
} );
|
|
}
|
|
|
|
module.exports = {
|
|
init: init,
|
|
update: update,
|
|
updatePageContents: updatePageContents,
|
|
refreshPageContents: refreshPageContents,
|
|
checkThreadItemOnPage: checkThreadItemOnPage,
|
|
getCheckboxesPromise: getCheckboxesPromise,
|
|
getApi: getApi,
|
|
storage: storage
|
|
};
|