2021-02-09 21:51:09 +00:00
|
|
|
<?php
|
|
|
|
/**
|
|
|
|
* DiscussionTools event dispatcher
|
|
|
|
*
|
|
|
|
* @file
|
|
|
|
* @ingroup Extensions
|
|
|
|
* @license MIT
|
|
|
|
*/
|
|
|
|
|
|
|
|
namespace MediaWiki\Extension\DiscussionTools\Notifications;
|
|
|
|
|
2021-07-22 18:05:34 +00:00
|
|
|
use ChangeTags;
|
|
|
|
use DeferredUpdates;
|
2021-02-09 21:51:09 +00:00
|
|
|
use EchoEvent;
|
|
|
|
use Error;
|
2021-06-02 19:12:16 +00:00
|
|
|
use IDBAccessObject;
|
2021-02-09 21:51:09 +00:00
|
|
|
use Iterator;
|
2021-07-16 16:01:18 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\CommentItem;
|
2021-02-09 21:51:09 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\CommentParser;
|
2021-06-25 13:24:46 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\HeadingItem;
|
2021-04-08 12:12:07 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
|
2021-02-09 21:51:09 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\SubscriptionItem;
|
2021-08-16 16:13:12 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\SubscriptionStore;
|
2021-07-16 16:01:18 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\ThreadItem;
|
2021-02-09 21:51:09 +00:00
|
|
|
use MediaWiki\MediaWikiServices;
|
2021-07-15 13:56:13 +00:00
|
|
|
use MediaWiki\Page\PageIdentity;
|
2021-02-09 21:51:09 +00:00
|
|
|
use MediaWiki\Revision\RevisionRecord;
|
|
|
|
use MediaWiki\User\UserIdentity;
|
2021-06-30 20:24:29 +00:00
|
|
|
use ParserOptions;
|
2021-04-08 12:12:07 +00:00
|
|
|
use Title;
|
2021-06-30 20:24:29 +00:00
|
|
|
use Wikimedia\Assert\Assert;
|
2021-08-02 13:45:39 +00:00
|
|
|
use Wikimedia\Parsoid\Utils\DOMCompat;
|
2021-02-09 21:51:09 +00:00
|
|
|
use Wikimedia\Parsoid\Utils\DOMUtils;
|
|
|
|
|
|
|
|
class EventDispatcher {
|
|
|
|
/**
|
2021-06-30 20:24:29 +00:00
|
|
|
* @param RevisionRecord $revRecord
|
2021-02-09 21:51:09 +00:00
|
|
|
* @return CommentParser
|
|
|
|
*/
|
2021-07-22 07:25:13 +00:00
|
|
|
private static function getParsedRevision( RevisionRecord $revRecord ): CommentParser {
|
2021-06-30 20:24:29 +00:00
|
|
|
$services = MediaWikiServices::getInstance();
|
|
|
|
|
2021-08-25 19:38:00 +00:00
|
|
|
$pageRecord = $services->getPageStore()->getPageByReference( $revRecord->getPage() ) ?:
|
|
|
|
$services->getPageStore()->getPageByReference( $revRecord->getPage(), IDBAccessObject::READ_LATEST );
|
|
|
|
|
2021-06-30 20:24:29 +00:00
|
|
|
Assert::postcondition( $pageRecord !== null, 'Revision had no page' );
|
|
|
|
|
|
|
|
// If the $revRecord was fetched from the primary database, this will also fetch the content
|
|
|
|
// from the primary database (using the same query flags)
|
|
|
|
$status = $services->getParserOutputAccess()->getParserOutput(
|
|
|
|
$pageRecord,
|
|
|
|
ParserOptions::newCanonical( 'canonical' ),
|
|
|
|
$revRecord
|
2021-02-09 21:51:09 +00:00
|
|
|
);
|
2021-06-30 20:24:29 +00:00
|
|
|
if ( !$status->isOK() ) {
|
|
|
|
throw new Error( 'Could not load revision for notifications' );
|
|
|
|
}
|
2021-02-09 21:51:09 +00:00
|
|
|
|
2021-06-30 20:24:29 +00:00
|
|
|
$parserOutput = $status->getValue();
|
|
|
|
$html = $parserOutput->getText();
|
2021-02-09 21:51:09 +00:00
|
|
|
|
2021-06-30 20:24:29 +00:00
|
|
|
$doc = DOMUtils::parseHTML( $html );
|
2021-08-02 13:45:39 +00:00
|
|
|
$container = DOMCompat::getBody( $doc );
|
2021-02-09 21:51:09 +00:00
|
|
|
return CommentParser::newFromGlobalState( $container );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param array &$events
|
|
|
|
* @param RevisionRecord $newRevRecord
|
|
|
|
*/
|
|
|
|
public static function generateEventsForRevision( array &$events, RevisionRecord $newRevRecord ) {
|
|
|
|
$services = MediaWikiServices::getInstance();
|
2021-04-08 12:16:54 +00:00
|
|
|
$dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
|
|
|
|
|
2021-06-07 20:45:54 +00:00
|
|
|
if ( !$dtConfig->get( 'DiscussionToolsEnableTopicSubscriptionBackend' ) ) {
|
2021-04-08 12:16:54 +00:00
|
|
|
// Feature disabled for all users
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-02-09 21:51:09 +00:00
|
|
|
$revisionStore = $services->getRevisionStore();
|
|
|
|
$userFactory = $services->getUserFactory();
|
2021-06-02 19:12:16 +00:00
|
|
|
$oldRevRecord = $revisionStore->getPreviousRevision( $newRevRecord, IDBAccessObject::READ_LATEST );
|
2021-02-09 21:51:09 +00:00
|
|
|
|
2021-04-08 12:12:07 +00:00
|
|
|
$title = Title::newFromLinkTarget(
|
|
|
|
$newRevRecord->getPageAsLinkTarget()
|
|
|
|
);
|
|
|
|
if ( !HookUtils::isAvailableForTitle( $title ) ) {
|
|
|
|
// Not a talk page
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-04-21 12:30:12 +00:00
|
|
|
$user = $newRevRecord->getUser();
|
|
|
|
if ( !$user ) {
|
|
|
|
// User can be null if the user is deleted, but this is unlikely
|
|
|
|
// to be the case if the user just made an edit
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-07-22 18:05:34 +00:00
|
|
|
if ( $oldRevRecord !== null ) {
|
|
|
|
$oldParser = self::getParsedRevision( $oldRevRecord );
|
|
|
|
} else {
|
|
|
|
// Page creation
|
|
|
|
$doc = DOMUtils::parseHTML( '' );
|
|
|
|
$container = DOMCompat::getBody( $doc );
|
|
|
|
$oldParser = CommentParser::newFromGlobalState( $container );
|
|
|
|
}
|
2021-02-09 21:51:09 +00:00
|
|
|
$newParser = self::getParsedRevision( $newRevRecord );
|
|
|
|
|
2021-07-15 13:56:13 +00:00
|
|
|
self::generateEventsFromParsers( $events, $oldParser, $newParser, $newRevRecord, $title, $user );
|
|
|
|
}
|
|
|
|
|
2021-07-16 16:01:18 +00:00
|
|
|
/**
|
2021-08-14 15:56:41 +00:00
|
|
|
* For each level 2 heading, get a list of comments in the thread grouped by names, then IDs.
|
2021-07-16 16:01:18 +00:00
|
|
|
* (Compare by name first, as ID could be changed by a parent comment being moved/deleted.)
|
2021-08-14 15:56:41 +00:00
|
|
|
* Comments in level 3+ sub-threads are grouped together with the parent thread.
|
|
|
|
*
|
|
|
|
* For any other headings (including level 3+ before the first level 2 heading, level 1, and
|
|
|
|
* section zero placeholder headings), ignore comments in those threads.
|
2021-07-16 16:01:18 +00:00
|
|
|
*
|
|
|
|
* @param ThreadItem[] $items
|
|
|
|
* @return CommentItem[][][]
|
|
|
|
*/
|
|
|
|
private static function groupCommentsByThreadAndName( array $items ): array {
|
|
|
|
$comments = [];
|
|
|
|
$threadName = null;
|
|
|
|
foreach ( $items as $item ) {
|
2021-08-14 15:56:41 +00:00
|
|
|
if ( $item instanceof HeadingItem && ( $item->getHeadingLevel() < 2 || $item->isPlaceholderHeading() ) ) {
|
|
|
|
$threadName = null;
|
|
|
|
} elseif ( $item instanceof HeadingItem && $item->getHeadingLevel() === 2 ) {
|
2021-07-16 16:01:18 +00:00
|
|
|
$threadName = $item->getName();
|
2021-08-12 20:37:51 +00:00
|
|
|
} elseif ( $item instanceof CommentItem && $threadName !== null ) {
|
2021-07-16 16:01:18 +00:00
|
|
|
$comments[ $threadName ][ $item->getName() ][ $item->getId() ] = $item;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return $comments;
|
|
|
|
}
|
|
|
|
|
2021-07-15 13:56:13 +00:00
|
|
|
/**
|
|
|
|
* Helper for generateEventsForRevision(), separated out for easier testing.
|
|
|
|
*
|
|
|
|
* @param array &$events
|
|
|
|
* @param CommentParser $oldParser
|
|
|
|
* @param CommentParser $newParser
|
|
|
|
* @param RevisionRecord $newRevRecord
|
|
|
|
* @param PageIdentity $title
|
|
|
|
* @param UserIdentity $user
|
|
|
|
*/
|
|
|
|
protected static function generateEventsFromParsers(
|
|
|
|
array &$events,
|
|
|
|
CommentParser $oldParser,
|
|
|
|
CommentParser $newParser,
|
|
|
|
RevisionRecord $newRevRecord,
|
|
|
|
PageIdentity $title,
|
|
|
|
UserIdentity $user
|
|
|
|
) {
|
2021-07-16 16:01:18 +00:00
|
|
|
$newComments = self::groupCommentsByThreadAndName( $newParser->getThreadItems() );
|
|
|
|
$oldComments = self::groupCommentsByThreadAndName( $oldParser->getThreadItems() );
|
|
|
|
$addedComments = [];
|
|
|
|
|
|
|
|
foreach ( $newComments as $threadName => $threadNewComments ) {
|
|
|
|
foreach ( $threadNewComments as $commentName => $nameNewComments ) {
|
|
|
|
// Usually, there will be 0 or 1 $nameNewComments, and 0 $nameOldComments,
|
|
|
|
// and $addedCount will be 0 or 1.
|
|
|
|
//
|
|
|
|
// But when multiple replies are added in one edit, or in multiple edits within the same
|
|
|
|
// minute, there may be more, and the complex logic below tries to make the best guess
|
|
|
|
// as to which comments are actually new. See the 'multiple' and 'sametime' test cases.
|
|
|
|
//
|
|
|
|
$nameOldComments = $oldComments[ $threadName ][ $commentName ] ?? [];
|
|
|
|
$addedCount = count( $nameNewComments ) - count( $nameOldComments );
|
|
|
|
|
|
|
|
if ( $addedCount > 0 ) {
|
|
|
|
// For any name that occurs more times in new than old, report that many new comments,
|
|
|
|
// preferring IDs that did not occur in old, then preferring comments lower in the thread.
|
|
|
|
foreach ( array_reverse( $nameNewComments ) as $commentId => $newComment ) {
|
|
|
|
if ( $addedCount > 0 && !isset( $nameOldComments[ $commentId ] ) ) {
|
|
|
|
$addedComments[] = $newComment;
|
|
|
|
$addedCount--;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
foreach ( array_reverse( $nameNewComments ) as $commentId => $newComment ) {
|
|
|
|
if ( $addedCount > 0 ) {
|
|
|
|
$addedComments[] = $newComment;
|
|
|
|
$addedCount--;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
Assert::postcondition( $addedCount === 0, 'Reported expected number of comments' );
|
|
|
|
}
|
2021-02-09 21:51:09 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$mentionedUsers = [];
|
|
|
|
foreach ( $events as $event ) {
|
|
|
|
if ( $event['type'] === 'mention' || $event['type'] === 'mention-summary' ) {
|
|
|
|
// Array is keyed by user id so we can do a simple array merge
|
|
|
|
$mentionedUsers += $event['extra']['mentioned-users'];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-22 18:05:34 +00:00
|
|
|
if ( $addedComments ) {
|
|
|
|
// It's a bit weird to do this here, in the middle of the hook handler for Echo. However:
|
|
|
|
// * Echo calls this from a PageSaveComplete hook handler as a DeferredUpdate,
|
|
|
|
// which is exactly how we would do this otherwise
|
|
|
|
// * It allows us to reuse the generated comment trees without any annoying caching
|
|
|
|
static::addCommentChangeTag( $newRevRecord );
|
|
|
|
}
|
|
|
|
|
2021-07-16 16:01:18 +00:00
|
|
|
foreach ( $addedComments as $newComment ) {
|
|
|
|
// Ignore comments by other users, e.g. in case of reverts or a discussion being moved.
|
|
|
|
// TODO: But what about someone signing another's comment?
|
|
|
|
if ( $newComment->getAuthor() !== $user->getName() ) {
|
|
|
|
continue;
|
|
|
|
}
|
2021-02-09 21:51:09 +00:00
|
|
|
$heading = $newComment->getHeading();
|
2021-06-25 13:24:46 +00:00
|
|
|
// Find a level 2 heading, because the interface doesn't allow subscribing to other headings.
|
|
|
|
// (T286736)
|
|
|
|
while ( $heading instanceof HeadingItem && $heading->getHeadingLevel() !== 2 ) {
|
|
|
|
$heading = $heading->getParent();
|
|
|
|
}
|
|
|
|
if ( !( $heading instanceof HeadingItem ) ) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
// Check if the name corresponds to a section that contain no comments (only sub-sections).
|
|
|
|
// The interface doesn't allow subscribing to them either, because they can't be distinguished
|
|
|
|
// from each other. (T285796)
|
|
|
|
if ( $heading->getName() === 'h-' ) {
|
|
|
|
continue;
|
|
|
|
}
|
2021-02-09 21:51:09 +00:00
|
|
|
$events[] = [
|
|
|
|
'type' => 'dt-subscribed-new-comment',
|
2021-04-08 12:12:07 +00:00
|
|
|
'title' => $title,
|
2021-02-09 21:51:09 +00:00
|
|
|
'extra' => [
|
|
|
|
'subscribed-comment-name' => $heading->getName(),
|
|
|
|
'comment-id' => $newComment->getId(),
|
2021-04-21 12:36:29 +00:00
|
|
|
'comment-name' => $newComment->getName(),
|
2021-02-09 21:51:09 +00:00
|
|
|
'content' => $newComment->getBodyText( true ),
|
Handle highlighting and scrolling to comments for bundled notifications
Notifications are bundled by section, so instead of linking to the
comment, link to the section.
Additionally, add a parameter to the URL listing all the comment IDs
from the bundle, and highlight them all and scroll to the topmost one.
Having to handle both URL fragments and URL query parameters makes
this code kind of a mess :(
Also, some unexpected changes became necessary:
* EventDispatcher.php: Store the section title in events using
HeadingItem::getLinkableTitle() instead of ThreadItem::getText().
The result is mostly the same, except in case of wacky markup like
images or extension tags. We can more reliably use it to link to the
section on the page, and we already use getLinkableTitle() when
generating edit summaries in the reply tool for this reason.
* dt.init.less: Change the mix-blend-mode for the highlights from
'multiply' to 'darken', so that multiple overlapping highlights do
not look more opaque. This affects how the highlights look on
non-white backgrounds and images (they're less blue, and on darker
backgrounds entirely invisible), but it seems worth it.
Bug: T286620
Change-Id: I21bb5c003abc2747f0350d3f3af558dfb55693e9
2021-08-05 20:04:17 +00:00
|
|
|
'section-title' => $heading->getLinkableTitle(),
|
2021-02-09 21:51:09 +00:00
|
|
|
'revid' => $newRevRecord->getId(),
|
|
|
|
'mentioned-users' => $mentionedUsers,
|
|
|
|
],
|
2021-04-21 12:30:12 +00:00
|
|
|
'agent' => $user,
|
2021-02-09 21:51:09 +00:00
|
|
|
];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-22 18:05:34 +00:00
|
|
|
/**
|
|
|
|
* Add our change tag for a revision that adds new comments.
|
|
|
|
*
|
|
|
|
* @param RevisionRecord $newRevRecord
|
|
|
|
*/
|
|
|
|
protected static function addCommentChangeTag( RevisionRecord $newRevRecord ) {
|
|
|
|
// Unclear if DeferredUpdates::addCallableUpdate() is needed,
|
|
|
|
// but every extension does it that way.
|
|
|
|
DeferredUpdates::addCallableUpdate( static function () use ( $newRevRecord ) {
|
|
|
|
ChangeTags::addTags( [ 'discussiontools-added-comment' ], null, $newRevRecord->getId() );
|
|
|
|
} );
|
|
|
|
}
|
|
|
|
|
2021-02-09 21:51:09 +00:00
|
|
|
/**
|
|
|
|
* Return all users subscribed to a comment
|
|
|
|
*
|
|
|
|
* @param EchoEvent $event
|
|
|
|
* @param int $batchSize
|
|
|
|
* @return UserIdentity[]|Iterator<UserIdentity>
|
|
|
|
*/
|
|
|
|
public static function locateSubscribedUsers( EchoEvent $event, $batchSize = 500 ) {
|
|
|
|
$commentName = $event->getExtraParam( 'subscribed-comment-name' );
|
|
|
|
|
|
|
|
$subscriptionStore = MediaWikiServices::getInstance()->getService( 'DiscussionTools.SubscriptionStore' );
|
|
|
|
$subscriptionItems = $subscriptionStore->getSubscriptionItemsForTopic(
|
|
|
|
$commentName,
|
2021-08-16 16:13:12 +00:00
|
|
|
SubscriptionStore::STATE_SUBSCRIBED
|
2021-02-09 21:51:09 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
// Update notified timestamps
|
|
|
|
$subscriptionStore->updateSubscriptionNotifiedTimestamp(
|
|
|
|
null,
|
|
|
|
$commentName
|
|
|
|
);
|
|
|
|
|
|
|
|
// TODD: Have this return an Iterator instead?
|
2021-05-05 06:59:38 +00:00
|
|
|
$users = array_map( static function ( SubscriptionItem $item ) {
|
2021-02-09 21:51:09 +00:00
|
|
|
return $item->getUserIdentity();
|
|
|
|
}, $subscriptionItems );
|
|
|
|
|
|
|
|
return $users;
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|