mediawiki-extensions-Discus.../modules/controller.js
Bartosz Dziewoński da11a3be73 When launched from an old revision, reply to latest revision
The "Reply" buttons were active when viewing an old revision of the
page (&oldid=1234). This was probably unintentional, and it would undo
all more recent comments if you saved yours.

However, I think it would be a useful feature. You often end up
viewing old revisions when reviewing changes to pages from your
watchlist or email notifications.

Now, when the reply widget is launched from an old revision, it will
try to find the relevant parent comment in the latest revision of the
page, and edit that revision when inserting the reply. If the parent
comment is gone, it shows a useful error message.

Bug: T235761
Change-Id: I8c5b631d3bfb62196fd219cbcd7d497408d187a7
2020-02-21 17:09:47 +00:00

268 lines
7.9 KiB
JavaScript

'use strict';
var
parser = require( 'ext.discussionTools.parser' ),
modifier = require( 'ext.discussionTools.modifier' ),
pageDataCache = {},
$pageContainer,
scrollPadding = { top: 10, bottom: 10 },
config = require( './config.json' ),
replyWidgetPromise = config.useVisualEditor ?
mw.loader.using( 'ext.discussionTools.ReplyWidgetVisual' ) :
mw.loader.using( 'ext.discussionTools.ReplyWidgetPlain' );
function setupComment( comment ) {
var $replyLink, widgetPromise, newListItem,
$tsNode = $( comment.range.endContainer );
// Is it possible to have a heading nested in a thread?
if ( comment.type !== 'comment' ) {
return;
}
$replyLink = $( '<a>' )
.addClass( 'dt-init-replylink' )
.text( mw.msg( 'discussiontools-replylink' ) )
.on( 'click', function () {
var $link = $( this );
$link.addClass( 'dt-init-replylink-active' );
// TODO: Allow users to use multiple reply widgets simlutaneously
// Currently as all widgets share the same Parsoid doc, this could
// cause problems.
$pageContainer.addClass( 'dt-init-replylink-open' );
if ( !widgetPromise ) {
newListItem = modifier.addListItem( comment );
$( newListItem ).text( mw.msg( 'discussiontools-replywidget-loading' ) );
widgetPromise = replyWidgetPromise.then( function () {
var
ReplyWidget = config.useVisualEditor ?
require( 'ext.discussionTools.ReplyWidgetVisual' ) :
require( 'ext.discussionTools.ReplyWidgetPlain' ),
replyWidget = new ReplyWidget(
comment
);
replyWidget.on( 'teardown', function () {
$link.removeClass( 'dt-init-replylink-active' );
$pageContainer.removeClass( 'dt-init-replylink-open' );
$( newListItem ).hide();
} );
$( newListItem ).empty().append( replyWidget.$element );
return replyWidget;
}, function () {
$link.removeClass( 'dt-init-replylink-active' );
$pageContainer.removeClass( 'dt-init-replylink-open' );
} );
}
widgetPromise.then( function ( replyWidget ) {
$( newListItem ).show();
replyWidget.setup();
replyWidget.scrollElementIntoView( { padding: scrollPadding } );
replyWidget.focus();
} );
} );
$tsNode.after( $replyLink );
}
function traverseNode( parent ) {
parent.replies.forEach( function ( comment ) {
setupComment( comment );
traverseNode( comment );
} );
}
function autoSignWikitext( wikitext ) {
wikitext = wikitext.trim();
if ( wikitext.slice( -4 ) !== '~~~~' ) {
wikitext += ' ~~~~';
}
return wikitext;
}
function postReply( widget, parsoidData ) {
var root, summary,
comment = parsoidData.comment,
pageData = parsoidData.pageData,
newParsoidItem = modifier.addListItem( comment );
widget.insertNewNodes( newParsoidItem );
root = comment;
while ( root && root.type !== 'heading' ) {
root = root.parent;
}
summary = '/* ' + root.range.startContainer.innerText + ' */ ' +
mw.msg( 'discussiontools-defaultsummary-reply' );
return mw.libs.ve.targetSaver.saveDoc(
parsoidData.doc,
{
page: pageData.pageName,
oldid: pageData.oldId,
summary: summary,
basetimestamp: pageData.baseTimeStamp,
starttimestamp: pageData.startTimeStamp,
etag: pageData.etag,
assert: mw.user.isAnon() ? 'anon' : 'user',
assertuser: mw.user.getName() || undefined,
// This appears redundant currently, but as editing / new-topics get added, we'll expand it
dttags: [ 'discussiontools', 'discussiontools-reply', 'discussiontools-' + widget.mode ].join( ',' )
}
);
}
function highlight( comment ) {
var padding = 5,
// $container must be position:relative/absolute
$container = OO.ui.getDefaultOverlay(),
containerRect = $container[ 0 ].getBoundingClientRect(),
nativeRange, rect,
$highlight = $( '<div>' ).addClass( 'dt-init-highlight' );
nativeRange = document.createRange();
nativeRange.setStart( comment.range.startContainer, comment.range.startOffset );
nativeRange.setEnd( comment.range.endContainer, comment.range.endOffset );
rect = RangeFix.getBoundingClientRect( nativeRange );
$highlight.css( {
top: rect.top - containerRect.top - padding,
left: rect.left - containerRect.left - padding,
width: rect.width + ( padding * 2 ),
height: rect.height + ( padding * 2 )
} );
setTimeout( function () {
$highlight.addClass( 'dt-init-highlight-fade' );
setTimeout( function () {
$highlight.remove();
}, 500 );
}, 500 );
$container.prepend( $highlight );
}
function commentsById( comments ) {
var byId = {};
comments.forEach( function ( comment ) {
byId[ comment.id ] = comment;
} );
return byId;
}
/**
* Get the Parsoid document HTML and metadata needed to edit this page from the API.
*
* 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
* @return {jQuery.Promise}
*/
function getPageData( pageName, oldId ) {
pageDataCache[ pageName ] = pageDataCache[ pageName ] || {};
if ( pageDataCache[ pageName ][ oldId ] ) {
return pageDataCache[ pageName ][ oldId ];
}
pageDataCache[ pageName ][ oldId ] = mw.loader.using( 'ext.visualEditor.targetLoader' ).then( function () {
return mw.libs.ve.targetLoader.requestPageData(
'visual', pageName, { oldId: oldId }
);
}, function () {
// Clear on failure
pageDataCache[ pageName ][ oldId ] = null;
} );
return pageDataCache[ pageName ][ oldId ];
}
/**
* Get the Parsoid document DOM, parse comments and threads, and find a specific comment in it.
*
* @param {string} pageName Page title
* @param {number} oldId Revision ID
* @param {string} commentId Comment ID, from a comment parsed in the local document
* @return {jQuery.Promise}
*/
function getParsoidCommentData( pageName, oldId, commentId ) {
var parsoidPageData, parsoidDoc, parsoidComments, parsoidCommentsById;
return getPageData( pageName, oldId )
.then( function ( response ) {
var data = response.visualeditor;
// TODO: error handling
parsoidDoc = ve.createDocumentFromHtml( data.content );
parsoidComments = parser.getComments( parsoidDoc.body );
parsoidPageData = {
pageName: pageName,
oldId: oldId
};
parsoidPageData.baseTimeStamp = data.basetimestamp;
parsoidPageData.startTimeStamp = data.starttimestamp;
parsoidPageData.etag = data.etag;
// getThreads build the tree structure, currently only
// used to set 'replies'
parser.groupThreads( parsoidComments );
parsoidCommentsById = commentsById( parsoidComments );
if ( !parsoidCommentsById[ commentId ] ) {
return $.Deferred().reject( 'comment-disappeared', { errors: [ {
code: 'comment-disappeared',
html: mw.message( 'discussiontools-error-comment-disappeared' ).parse()
} ] } ).promise();
}
return {
comment: parsoidCommentsById[ commentId ],
doc: parsoidDoc,
pageData: parsoidPageData
};
} );
}
function init( $container, state ) {
var
pageComments, pageThreads, pageCommentsById,
repliedToComment;
state = state || {};
$pageContainer = $container;
pageComments = parser.getComments( $pageContainer[ 0 ] );
pageThreads = parser.groupThreads( pageComments );
pageCommentsById = commentsById( pageComments );
pageThreads.forEach( traverseNode );
$pageContainer.addClass( 'dt-init-done' );
$pageContainer.removeClass( 'dt-init-replylink-open' );
// For debugging
mw.dt.pageThreads = pageThreads;
if ( state.repliedTo ) {
// Find the comment we replied to, then highlight the last reply
repliedToComment = pageCommentsById[ state.repliedTo ];
highlight( repliedToComment.replies[ repliedToComment.replies.length - 1 ] );
}
// Preload the Parsoid document.
// 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' )
);
}
module.exports = {
init: init,
getParsoidCommentData: getParsoidCommentData,
postReply: postReply,
autoSignWikitext: autoSignWikitext
};