' )
.addClass( 'ext-discussiontools-autotopicsubpopup-body' )
.text( mw.msg( 'discussiontools-autotopicsubpopup-body' ) ),
$( '
' )
.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 `
` 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.} 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} [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 `` 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
};