'use strict'; const featuresEnabled = mw.config.get( 'wgDiscussionToolsFeaturesEnabled' ) || {}, MemoryStorage = require( './MemoryStorage.js' ), STORAGE_EXPIRY = 60 * 60 * 24 * 30, Parser = require( './Parser.js' ), ThreadItemSet = require( './ThreadItemSet.js' ), CommentDetails = require( './CommentDetails.js' ), HeadingItem = require( './HeadingItem.js' ), ReplyLinksController = require( './ReplyLinksController.js' ), utils = require( './utils.js' ), highlighter = require( './highlighter.js' ), topicSubscriptions = require( './topicsubscriptions.js' ), permalinks = require( './permalinks.js' ), defaultEditMode = mw.user.options.get( 'discussiontools-editmode' ) || mw.config.get( 'wgDiscussionToolsFallbackEditMode' ), defaultVisual = defaultEditMode === 'visual', enable2017Wikitext = featuresEnabled.sourcemodetoolbar, overflowMenu = require( './overflowMenu.js' ); let $pageContainer, linksController, pageThreads, lastControllerScrollOffset, pageDataCache = {}, pageHandlersSetup = false; let mobile = null; if ( OO.ui.isMobile() && mw.config.get( 'skin' ) === 'minerva' ) { mobile = require( './mobile.js' ); } require( './thanks.js' ); 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' ) } } ); } /** * 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 {Object} [apiParams] Additional parameters for the API * @return {jQuery.Promise} */ function getPageData( pageName, oldId, apiParams ) { const api = getApi(); apiParams = apiParams || {}; pageDataCache[ pageName ] = pageDataCache[ pageName ] || {}; if ( pageDataCache[ pageName ][ oldId ] && $.isEmptyObject( apiParams ) ) { return pageDataCache[ pageName ][ oldId ]; } let lintPromise, transcludedFromPromise; if ( oldId ) { lintPromise = api.get( { action: 'query', list: 'linterrors', lntcategories: 'fostered', lntlimit: 1, lnttitle: pageName } ).then( ( response ) => OO.getProp( response, 'query', 'linterrors' ) || [] ); transcludedFromPromise = api.get( { action: 'discussiontoolspageinfo', page: pageName, oldid: oldId } ).then( ( response ) => OO.getProp( response, 'discussiontoolspageinfo', 'transcludedfrom' ) || {} ); } else { lintPromise = $.Deferred().resolve( [] ).promise(); transcludedFromPromise = $.Deferred().resolve( {} ).promise(); } const veMetadataPromise = api.get( Object.assign( { action: 'visualeditor', paction: 'metadata', page: pageName }, apiParams ) ).then( ( response ) => OO.getProp( response, 'visualeditor' ) || [] ); const promise = $.when( lintPromise, transcludedFromPromise, veMetadataPromise ) .then( ( linterrors, transcludedfrom, metadata ) => ( { linterrors: linterrors, transcludedfrom: transcludedfrom, metadata: metadata } ), () => { // Clear on failure pageDataCache[ pageName ][ oldId ] = null; // Let caller handle the error return $.Deferred().rejectWith( this, arguments ); } ); if ( $.isEmptyObject( apiParams ) ) { pageDataCache[ pageName ][ oldId ] = promise; } return promise; } /** * 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 ) { const isNewTopic = threadItem.id === utils.NEW_TOPIC_COMMENT_ID; const defaultMode = mw.user.options.get( 'discussiontools-editmode' ) || mw.config.get( 'wgDiscussionToolsFallbackEditMode' ); let apiParams = null; if ( isNewTopic ) { apiParams = { section: 'new', editintro: threadItem.editintro, preload: threadItem.preload, preloadparams: threadItem.preloadparams, paction: defaultMode === 'source' ? 'wikitext' : 'parse' }; } return getPageData( pageName, oldId, apiParams ) .then( ( response ) => { const 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. let 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 ) { const mwTitle = isTranscludedFrom === true ? null : mw.Title.newFromText( isTranscludedFrom ); // If this refers to a template rather than a subpage, we never want to edit it const follow = mwTitle && mwTitle.getNamespaceId() !== mw.config.get( 'wgNamespaceIds' ).template; let transcludedErrMsg; if ( follow ) { transcludedErrMsg = mw.message( 'discussiontools-error-comment-is-transcluded-title', mwTitle.getPrefixedText() ).parse(); } else if ( metadata.canEdit ) { // If the user can edit, advise them to use the edit button transcludedErrMsg = mw.message( 'discussiontools-error-comment-is-transcluded', // eslint-disable-next-line no-jquery/no-global-selector $( '#ca-edit' ).text() ).parse(); } else { // Otherwise, tell them why they can't edit transcludedErrMsg = metadata.notices[ 'permissions-error' ]; } 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 const 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, metadata.wouldautocreate, metadata.content, defaultMode ); } ); } /** * 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( ( pageData ) => { const 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( () => 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 } ); } /** * Get the resourceloader modules required for a mode of the reply widget * * @return {string[]} */ function getReplyWidgetModules() { const modules = []; if ( OO.ui.isMobile() ) { modules.push( 'ext.visualEditor.core.mobile', 'ext.visualEditor.mwextensions' ); } else { modules.push( 'ext.visualEditor.core.desktop', 'ext.visualEditor.desktopTarget', 'ext.visualEditor.mwextensions.desktop' ); } modules.push( 'ext.discussionTools.ReplyWidget' ); const veConf = mw.config.get( 'wgVisualEditorConfig' ); modules.push( ...veConf.pluginModules.filter( mw.loader.getState ) ); return modules; } /** * Initialize Discussion Tools features * * @param {jQuery} $container Page container * @param {Object<string,any>} [state] Page state data object * @param {string} [state.repliedTo] The comment ID that was just replied to * @param {boolean} [state.tempUserCreated] Whether a temp user was just created */ function init( $container, state ) { let activeCommentId = null, activeController = null; // Loads later to avoid circular dependency const CommentController = require( './CommentController.js' ), NewTopicController = require( './NewTopicController.js' ); // We may be re-initializing after posting a new comment, so clear the cache, because // wgCurRevisionId will not change if we posted to a transcluded page (T266275). // This also applies when another tool has posted a comment and reloaded page contents (T323661). pageDataCache = {}; // Lazy-load postEdit module, may be required later (on desktop) mw.loader.using( 'mediawiki.action.view.postEdit' ); if ( linksController ) { linksController.teardown(); linksController = null; } $pageContainer = $container; linksController = new ReplyLinksController( $pageContainer ); permalinks.init( $pageContainer ); linksController.on( 'link-interact', () => { // Preload page metadata when the user is about to use a link, to make the tool load faster. // NOTE: As of January 2023, this is an EXPENSIVE API CALL. It must not be done on every // pageview, as that would generate enough load to take down Wikimedia sites (T325477). // It would be barely acceptable to do it on every *discussion* pageview, but we're trying // to be better and only do it when really needed (T325598). getPageData( mw.config.get( 'wgRelevantPageName' ), mw.config.get( 'wgCurRevisionId' ) ); } ); const parser = new Parser( require( './parser/data.json' ) ); const commentNodes = $pageContainer[ 0 ].querySelectorAll( '[data-mw-thread-id]' ); pageThreads = ThreadItemSet.static.newFromJSON( mw.config.get( 'wgDiscussionToolsPageThreads' ) || [], $pageContainer[ 0 ], parser ); if ( featuresEnabled.topicsubscription ) { topicSubscriptions.initTopicSubscriptions( $container, pageThreads ); } overflowMenu.init( $container, pageThreads ); if ( mobile ) { mobile.init( $container, pageThreads ); } if ( linksController.pageHasReplyLinks() || linksController.pageHasNewTopicLink() ) { // Start loading reply widget code // The worst-case here is that we might be on a page with no comments // and the add-topic link suppressed, *but* which has valid links to // trigger the new topic tool within the content. If that happens, // the modules will still be loaded when those links are interacted with. mw.loader.using( getReplyWidgetModules() ); } /** * Setup comment controllers for each comment, and the new topic controller * * @param {ThreadItem} comment * @param {jQuery} $link Add section link for new topic controller * @param {Object} [options] Options, see CommentController * @param {string} [options.mode] Optionally force a mode, 'visual' or 'source' * @param {boolean} [options.hideErrors] Suppress errors, e.g. when restoring auto-save * @param {boolean} [options.suppressNotifications] Don't notify the user if recovering auto-save * @param {MemoryStorage} [storage] Storage object for autosave */ function setupController( comment, $link, options, storage ) { if ( !storage ) { storage = new MemoryStorage( mw.storage, 'mw-ext-DiscussionTools-reply/' + comment.id, STORAGE_EXPIRY ); } let commentController, $addSectionLink; if ( comment.id === utils.NEW_TOPIC_COMMENT_ID ) { // eslint-disable-next-line no-jquery/no-global-selector $addSectionLink = $( '#ca-addsection' ).find( '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, comment, pageThreads, storage ); } else { commentController = new CommentController( $pageContainer, comment, pageThreads, storage ); } activeCommentId = comment.id; activeController = commentController; linksController.setActiveLink( $link ); commentController.on( 'teardown', ( teardownMode ) => { activeCommentId = null; activeController = null; if ( teardownMode !== 'refresh' ) { linksController.clearActiveLink(); } if ( teardownMode === 'abandoned' ) { linksController.focusLink( $link ); } } ); commentController.on( 'reloadPage', () => { mw.dt.initState.newCommentIds = commentController.newComments.map( ( cmt ) => cmt.id ); // Teardown active reply widget(s) commentController.replyWidgetPromise.then( ( replyWidget ) => { lastControllerScrollOffset = $( commentController.newListItem ).offset().top; replyWidget.teardown( 'refresh' ); // Only fetch the last now "good" revision, on which we know the parent comment still exists. // As we poll frequently, this will almost always be the lastet revision. refreshPageContents( commentController.oldId ); } ); } ); commentController.setup( options ); if ( lastControllerScrollOffset ) { $( document.documentElement ).scrollTop( $( document.documentElement ).scrollTop() + ( $( commentController.newListItem ).offset().top - lastControllerScrollOffset ) ); lastControllerScrollOffset = null; } } function newTopicComment( data ) { const comment = new HeadingItem( {}, 2 ); comment.id = utils.NEW_TOPIC_COMMENT_ID; comment.isNewTopic = true; Object.assign( comment, data ); return comment; } // 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', ( commentId, $link, data ) => { // 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. if ( activeCommentId === commentId ) { activeController.showAndFocus(); return; } let teardownPromise = $.Deferred().resolve(); if ( commentId === utils.NEW_TOPIC_COMMENT_ID ) { // If this is a new topic link, and a reply widget is open, attempt to close it first. if ( activeController ) { teardownPromise = activeController.tryTeardown(); } else if ( OO.getProp( window, 've', 'init', 'target', 'tryTeardown' ) ) { // If VE or 2017WTE is open, attempt to close it as well. (T317035#8590357) // FIXME This should be generalized, using some global router or something, // so that we don't try to open while something else is open on full screen. // Another example is the MultimediaViewer extension. teardownPromise = ve.init.target.tryTeardown() || $.Deferred().resolve(); } } if ( mobile ) { teardownPromise = teardownPromise.then( () => mobile.closeLedeSectionDialog() ); } teardownPromise.then( () => { // If another reply widget is open (or opening), do nothing. if ( activeController ) { return; } let comment; if ( commentId !== utils.NEW_TOPIC_COMMENT_ID ) { comment = pageThreads.findCommentById( commentId ); } else { comment = newTopicComment( data ); } if ( comment ) { setupController( comment, $link ); } else { // We couldn't find the comment, so express that there's an issue mw.notify( mw.msg( 'discussiontools-error-comment-disappeared' ) ); } } ); } ); const mobilePromise = OO.ui.isMobile() && mw.loader.getState( 'mobile.init' ) ? mw.loader.using( 'mobile.init' ) : $.Deferred().resolve().promise(); // Restore autosave // Don't do anything when we're editing/previewing if ( mw.config.get( 'wgAction' ) === 'view' ) { pageThreads.threadItems.every( ( comment, i ) => { const replyStorage = new MemoryStorage( mw.storage, 'mw-ext-DiscussionTools-reply/' + comment.id, STORAGE_EXPIRY ); if ( replyStorage.get( 'saveable' ) ) { const mode = replyStorage.get( 'mode' ); const $link = $( commentNodes[ i ] ); // Wait for mobile section toggling code to be ready mobilePromise.then( () => { if ( OO.ui.isMobile() ) { const urlFragment = mw.util.escapeIdForLink( comment.id ); // Force the section to expand on mobile (T338920) location.hash = '#' + urlFragment; } // Wait for the 'hashchange' event to be handled by the mobile code setTimeout( () => { setupController( comment, $link, { mode: mode, hideErrors: true, suppressNotifications: !state.firstLoad }, replyStorage ); } ); } ); return false; } return true; } ); const newTopicStorage = new MemoryStorage( mw.storage, 'mw-ext-DiscussionTools-reply/' + utils.NEW_TOPIC_COMMENT_ID, STORAGE_EXPIRY ); if ( newTopicStorage.get( 'saveable' ) || newTopicStorage.get( 'title' ) ) { const mode = newTopicStorage.get( 'mode' ); setupController( newTopicComment(), $( [] ), { mode: mode, hideErrors: true, suppressNotifications: !state.firstLoad }, newTopicStorage ); } else if ( mw.config.get( 'wgDiscussionToolsStartNewTopicTool' ) ) { const data = linksController.parseNewTopicLink( location.href ); setupController( newTopicComment( data ), $( [] ) ); } } // For debugging (now unused in the code) mw.dt.pageThreads = pageThreads; mobilePromise.then( () => { if ( state.repliedTo ) { highlighter.highlightPublishedComment( pageThreads, state.repliedTo ); if ( state.repliedTo === utils.NEW_TOPIC_COMMENT_ID ) { mw.hook( 'postEdit' ).fire( { tempUserCreated: state.tempUserCreated, message: mw.msg( 'discussiontools-postedit-confirmation-topicadded', mw.user ) } ); } else { mw.hook( 'postEdit' ).fire( { tempUserCreated: state.tempUserCreated, message: mw.msg( 'discussiontools-postedit-confirmation-published', mw.user ) } ); } } else if ( state.newCommentIds ) { highlighter.highlightNewComments( pageThreads, true, state.newCommentIds ); } // 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' ) ) { topicSubscriptions.updateAutoSubscriptionStates( $container, pageThreads, state.repliedTo ); } } ); let dismissableNotificationPromise = null; // Page-level handlers only need to be setup once if ( !pageHandlersSetup ) { $( window ).on( 'popstate', () => { // 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( () => { highlighter.highlightTargetComment( pageThreads, true ); } ); } ); // eslint-disable-next-line no-jquery/no-global-selector $( 'body' ).on( 'click', ( e ) => { if ( utils.isUnmodifiedLeftClick( e ) && !e.target.closest( 'a' ) ) { // Remove the highlight and the hash from the URL, unless clicking on another link highlighter.clearHighlightTargetComment( pageThreads ); } if ( dismissableNotificationPromise ) { dismissableNotificationPromise.then( ( notif ) => { notif.close(); } ); dismissableNotificationPromise = null; } } ); pageHandlersSetup = true; } if ( state.firstLoad ) { mobilePromise.then( () => { let findCommentQuery; let isHeading = false; const highlightResult = highlighter.highlightTargetComment( pageThreads ); // Hash contains a non-replaced space (should be underscore), maybe due to // manual creation or a broken third party tool. Just replace the spaces // and navigate to the new URL. // Ideally we'd use history.replaceState but that wouldn't scroll the page. // Only do the replacement if the original hash doesn't correspond to a target // element, but the fixed hash does, to avoid affects on other apps which // may use fragments with spaces. if ( location.hash && !mw.util.getTargetFromFragment() && location.hash.indexOf( '%20' ) !== -1 ) { const fixedHash = location.hash.slice( 1 ).replace( /%20/g, '_' ); if ( mw.util.getTargetFromFragment( fixedHash ) ) { location.hash = fixedHash; } } if ( // Fragment doesn't correspond to an element on the page location.hash && !mw.util.getTargetFromFragment() && // Not a DT comment highlightResult.highlighted.length === 0 && highlightResult.requested.length === 0 ) { const fragment = location.hash.slice( 1 ); const ignorePatterns = [ // A leading '/' or '!/' usually means a application route, e.g. /media, or /editor. // We can't rule out a heading title (T349498), but they are unlikely /^!?\//, // "top" is a magic value in the WHATWG spec that goes to the top of the // document (unless there's an actual id=top element on the page), see: // https://html.spec.whatwg.org/multipage/browsing-the-web.html#scrolling-to-a-fragment:top-of-the-document-2 // There's a very rare edge case of actual headings named "top" that we'll // be missing here, but they're far less common than the top-of-page usage. /^top$/i, // Gadget: ConvenientDiscussions /^\d{12}_/, // Gadget: RedWarn /^noticeApplied-/ ]; if ( ignorePatterns.every( ( pattern ) => !pattern.test( fragment ) ) ) { findCommentQuery = { heading: mw.util.percentDecodeFragment( fragment ).replace( / /g, '_' ), page: mw.config.get( 'wgRelevantPageName' ) }; isHeading = true; } } else if ( highlightResult.highlighted.length === 0 && highlightResult.requested.length === 1 ) { findCommentQuery = { idorname: mw.util.percentDecodeFragment( highlightResult.requested[ 0 ] ) }; isHeading = highlightResult.requested[ 0 ].slice( 0, 1 ) === 'h'; } if ( findCommentQuery ) { // TODO: Support multiple commentIds being requested and not all being found const dtConf = require( './config.json' ); const findCommentRequest = dtConf.enablePermalinksFrontend ? getApi().get( Object.assign( { action: 'discussiontoolsfindcomment' }, findCommentQuery ) ) : $.Deferred().resolve( [ {} ] ).promise(); dismissableNotificationPromise = $.when( findCommentRequest, mw.loader.using( 'mediawiki.notification' ) ).then( ( results ) => { const result = results[ 0 ]; let titles = []; if ( result.discussiontoolsfindcomment ) { titles = result.discussiontoolsfindcomment.map( ( threadItemData ) => { // Only show items that appear on the current revision of their page // and are not transcluded from another page if ( threadItemData.couldredirect ) { const title = mw.Title.newFromText( threadItemData.title + '#' + mw.util.escapeIdForLink( threadItemData.id ) ); return title; } return null; } ).filter( ( url ) => url ); } if ( titles.length ) { const $list = $( '<ul>' ); const $notification = $( '<div>' ).append( $( '<p>' ).text( mw.message( isHeading ? 'discussiontools-target-heading-found-moved' : 'discussiontools-target-comment-found-moved', titles.length ).text() ), $list ); titles.forEach( ( title ) => { $list.append( $( '<li>' ).append( $( '<a>' ).attr( 'href', title.getUrl() ).text( title.getPrefixedText() ) ) ); } ); mw.notification.notify( $notification, { type: 'warn', autoHide: false } ); // This notification should not be accidentally dismissable return $.Deferred().reject().promise(); } else { return mw.notification.notify( mw.message( isHeading ? 'discussiontools-target-heading-missing' : 'discussiontools-target-comment-missing' ).text(), { type: 'warn', autoHide: false } ); } } ); } if ( highlightResult.highlighted.length === 0 && ( highlightResult.requested.length > 1 || highlightResult.requestedSince ) ) { dismissableNotificationPromise = mw.loader.using( 'mediawiki.notification' ).then( () => mw.notification.notify( mw.message( 'discussiontools-target-comments-missing' ).text(), { type: 'warn', autoHide: false } ) ); } } ); } } /** * 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 ) { $container.find( '.mw-parser-output' ).first().replaceWith( data.parse.text ); mw.util.clearSubtitle(); mw.util.addSubtitle( data.parse.subtitle ); // eslint-disable-next-line no-jquery/no-global-selector if ( $( '#catlinks' ).length ) { const $categories = $( $.parseHTML( data.parse.categorieshtml ) ); mw.hook( 'wikipage.categories' ).fire( $categories ); // eslint-disable-next-line no-jquery/no-global-selector $( '#catlinks' ).replaceWith( $categories ); } 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 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 ); mw.hook( 'wikipage.tableOfContents' ).fire( data.parse.showtoc ? data.parse.sections : [] ); // Copied from ve.init.mw.DesktopArticleTarget.prototype.saveComplete // TODO: Upstream this to core/skins, triggered by a hook (wikipage.content?) // eslint-disable-next-line no-jquery/no-global-selector $( '#t-permalink' ).add( '#coll-download-as-rl' ).find( 'a' ).each( ( i, link ) => { const permalinkUrl = new URL( link.href ); permalinkUrl.searchParams.set( 'oldid', data.parse.revid ); $( link ).attr( 'href', permalinkUrl.toString() ); } ); const url = new URL( location.href ); url.searchParams.delete( 'oldid' ); // If there are any other query parameters left, re-use that URL object. // Otherwise use the canonical style view url (T44553, T102363). const keys = []; url.searchParams.forEach( ( val, key ) => { keys.push( key ); } ); if ( !keys.length || ( keys.length === 1 && keys[ 0 ] === 'title' ) ) { const viewUrl = new URL( mw.util.getUrl( mw.config.get( 'wgRelevantPageName' ) ), document.baseURI ); viewUrl.hash = location.hash; history.pushState( null, '', viewUrl ); } else { history.pushState( null, '', url ); } } /** * Load the latest revision of the page and display its contents. * * @param {number} [oldId] Revision ID to fetch, latest if not specified * @return {jQuery.Promise} Promise which resolves when the refresh is complete */ function refreshPageContents( oldId ) { // This should approximately match the API call in ApiVisualEditorEditor#parseWikitext 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' ), mobileformat: OO.ui.isMobile(), uselang: mw.config.get( 'wgUserLanguage' ), // Pass through dtenable query string param from original request dtenable: new URLSearchParams( location.search ).get( 'dtenable' ) ? '1' : undefined, prop: [ 'text', 'revid', 'categorieshtml', 'sections', 'displaytitle', 'subtitle', 'modules', 'jsconfigvars' ], page: !oldId ? mw.config.get( 'wgRelevantPageName' ) : undefined, oldid: oldId || undefined } ).then( ( 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 ) { function logSaveSuccess() { mw.track( 'editAttemptStep', { action: 'saveSuccess', timing: mw.now() - replyWidget.commentController.saveInitiated, // eslint-disable-next-line camelcase revision_id: data.newrevid } ); } if ( ( pageName === mw.config.get( 'wgRelevantPageName' ) && data.nocontent ) || data.tempusercreated ) { // Reload if the page didn't exist before this update, or we just became logged in // as a temporary user. 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 ); const params = { dtrepliedto: threadItem.id }; if ( data.tempusercreated ) { params.dttempusercreated = '1'; } window.location = data.tempusercreatedredirect || mw.util.getUrl( pageName, params ); logSaveSuccess(); return; } replyWidget.teardown(); // TODO: Tell controller to teardown all other open widgets // Highlight the new reply after re-initializing mw.dt.initState.repliedTo = threadItem.id; mw.dt.initState.tempUserCreated = data.tempusercreated; // Update page state const pageUpdated = $.Deferred(); if ( pageName === mw.config.get( 'wgRelevantPageName' ) ) { // We can use the result from the VisualEditor API updatePageContents( $pageContainer, { parse: { text: data.content, subtitle: data.contentSub, categorieshtml: data.categorieshtml, jsconfigvars: data.jsconfigvars, revid: data.newrevid, // Note: VE API merges 'modules' and 'modulestyles' modules: data.modules, modulestyles: [], // Note: VE API drops 'showtoc' and changes 'sections' depending on it showtoc: true, sections: data.sections } } ); 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 const api = getApi(); api.post( { action: 'purge', titles: mw.config.get( 'wgRelevantPageName' ) } ).then( () => refreshPageContents() ).then( () => { pageUpdated.resolve(); } ).catch( () => { // 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 } ); // We're confident the saving portion succeeded, so still log this: logSaveSuccess(); } ); } // User logged in if module loaded. if ( mw.loader.getState( 'mediawiki.page.watch.ajax' ) === 'ready' ) { const watch = require( 'mediawiki.page.watch.ajax' ); watch.updateWatchLink( mw.Title.newFromText( pageName ), data.watched ? 'unwatch' : 'watch', 'idle', data.watchlistexpiry ); } pageUpdated.then( logSaveSuccess ); } module.exports = { init: init, update: update, updatePageContents: updatePageContents, refreshPageContents: refreshPageContents, checkThreadItemOnPage: checkThreadItemOnPage, getCheckboxesPromise: getCheckboxesPromise, getApi: getApi, getReplyWidgetModules: getReplyWidgetModules, defaultVisual: defaultVisual, enable2017Wikitext: enable2017Wikitext };