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;
|
2021-11-08 18:00:25 +00:00
|
|
|
use DateInterval;
|
|
|
|
use DateTimeImmutable;
|
2021-07-22 18:05:34 +00:00
|
|
|
use DeferredUpdates;
|
2021-02-09 21:51:09 +00:00
|
|
|
use EchoEvent;
|
2021-10-18 08:32:17 +00:00
|
|
|
use ExtensionRegistry;
|
2021-06-02 19:12:16 +00:00
|
|
|
use IDBAccessObject;
|
2021-02-09 21:51:09 +00:00
|
|
|
use Iterator;
|
2023-01-31 15:50:34 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\CommentUtils;
|
2022-03-18 03:28:06 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\ContentThreadItemSet;
|
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;
|
2022-03-18 03:28:06 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem;
|
|
|
|
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
|
2022-07-30 21:59:55 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem;
|
2022-03-18 03:28:06 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
|
|
|
|
use MediaWiki\Extension\DiscussionTools\ThreadItem\HeadingItem;
|
2022-03-06 16:10:40 +00:00
|
|
|
use MediaWiki\Extension\EventLogging\EventLogging;
|
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-10-18 08:32:17 +00:00
|
|
|
use RequestContext;
|
2022-10-28 18:24:02 +00:00
|
|
|
use RuntimeException;
|
2021-04-08 12:12:07 +00:00
|
|
|
use Title;
|
2022-02-21 22:07:38 +00:00
|
|
|
use TitleValue;
|
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
|
2022-03-18 03:28:06 +00:00
|
|
|
* @return ContentThreadItemSet
|
2021-02-09 21:51:09 +00:00
|
|
|
*/
|
2022-03-18 03:28:06 +00:00
|
|
|
private static function getParsedRevision( RevisionRecord $revRecord ): ContentThreadItemSet {
|
2021-06-30 20:24:29 +00:00
|
|
|
$services = MediaWikiServices::getInstance();
|
|
|
|
|
2021-12-10 12:33:02 +00:00
|
|
|
$pageRecord = $services->getPageStore()->getPageById( $revRecord->getPageId() ) ?:
|
|
|
|
$services->getPageStore()->getPageById( $revRecord->getPageId(), IDBAccessObject::READ_LATEST );
|
2021-08-25 19:38:00 +00:00
|
|
|
|
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,
|
2021-12-18 19:15:02 +00:00
|
|
|
ParserOptions::newFromAnon(),
|
2021-06-30 20:24:29 +00:00
|
|
|
$revRecord
|
2021-02-09 21:51:09 +00:00
|
|
|
);
|
2021-06-30 20:24:29 +00:00
|
|
|
if ( !$status->isOK() ) {
|
2022-10-28 18:24:02 +00:00
|
|
|
throw new RuntimeException( 'Could not load revision for notifications' );
|
2021-06-30 20:24:29 +00:00
|
|
|
}
|
2021-02-09 21:51:09 +00:00
|
|
|
|
2022-02-21 22:07:38 +00:00
|
|
|
$title = TitleValue::newFromPage( $revRecord->getPage() );
|
2022-01-11 15:50:44 +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 );
|
Change CommentParser into a service
Goal:
-----
To have a method like CommentParser::parse(), which just takes a node
to parse and a title and returns plain data, so that we don't need to
keep track of the config to construct a CommentParser object (the
required config like content language is provided by services) and
we don't need to keep that object around after parsing.
Changes:
--------
CommentParser.php:
* …is now a service. Constructor only takes services as arguments.
The node and title are passed to a new parse() method.
* parse() should return plain data, but I split this part to a separate
patch for ease of review: I49bfe019aa460651447fd383f73eafa9d7180a92.
* CommentParser still cheats and accesses global state in a few places,
e.g. calling Title::makeTitleSafe or CommentUtils::getTitleFromUrl,
so we can't turn its tests into true unit tests. This work is left
for future commits.
LanguageData.php:
* …is now a service, instead of a static class.
Parser.js:
* …is not a real service, but it's changed to behave in a similar way.
Constructor takes only the required config as argument,
and node and title are instead passed to a new parse() method.
CommentParserTest.php:
parser.test.js:
* Can be simplified, now that we don't need a useless node and title
to test internal methods that don't use them.
testUtils.js:
* Can be simplified, now that we don't need to override internal
ResourceLoader stuff just to change the parser config.
Change-Id: Iadb7757debe000025e52770ca51ebcf24ca8ee66
2022-02-19 02:43:21 +00:00
|
|
|
$parser = $services->getService( 'DiscussionTools.CommentParser' );
|
|
|
|
return $parser->parse( $container, $title );
|
2021-02-09 21:51:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param array &$events
|
|
|
|
* @param RevisionRecord $newRevRecord
|
|
|
|
*/
|
2021-12-01 14:53:20 +00:00
|
|
|
public static function generateEventsForRevision( array &$events, RevisionRecord $newRevRecord ): void {
|
2021-02-09 21:51:09 +00:00
|
|
|
$services = MediaWikiServices::getInstance();
|
2021-04-08 12:16:54 +00:00
|
|
|
|
2021-02-09 21:51:09 +00:00
|
|
|
$revisionStore = $services->getRevisionStore();
|
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 ) {
|
2022-06-09 13:51:33 +00:00
|
|
|
$oldItemSet = static::getParsedRevision( $oldRevRecord );
|
2021-07-22 18:05:34 +00:00
|
|
|
} else {
|
|
|
|
// Page creation
|
|
|
|
$doc = DOMUtils::parseHTML( '' );
|
|
|
|
$container = DOMCompat::getBody( $doc );
|
2022-02-19 06:31:34 +00:00
|
|
|
$oldItemSet = $services->getService( 'DiscussionTools.CommentParser' )
|
2022-02-21 22:07:38 +00:00
|
|
|
->parse( $container, $title->getTitleValue() );
|
2021-07-22 18:05:34 +00:00
|
|
|
}
|
2022-06-09 13:51:33 +00:00
|
|
|
$newItemSet = static::getParsedRevision( $newRevRecord );
|
2021-02-09 21:51:09 +00:00
|
|
|
|
2022-06-09 13:51:33 +00:00
|
|
|
static::generateEventsFromItemSets( $events, $oldItemSet, $newItemSet, $newRevRecord, $title, $user );
|
2021-07-15 13:56:13 +00:00
|
|
|
}
|
|
|
|
|
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
|
|
|
*
|
2022-03-18 03:28:06 +00:00
|
|
|
* @param ContentThreadItem[] $items
|
|
|
|
* @return ContentCommentItem[][][]
|
2021-07-16 16:01:18 +00:00
|
|
|
*/
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2022-07-30 21:59:55 +00:00
|
|
|
/**
|
|
|
|
* Get a list of all subscribable headings, grouped by name in case there are duplicates.
|
|
|
|
*
|
|
|
|
* @param ContentHeadingItem[] $items
|
|
|
|
* @return ContentHeadingItem[][]
|
|
|
|
*/
|
|
|
|
private static function groupSubscribableHeadings( array $items ): array {
|
|
|
|
$headings = [];
|
|
|
|
foreach ( $items as $item ) {
|
|
|
|
if ( $item->isSubscribable() ) {
|
|
|
|
$headings[ $item->getName() ][ $item->getId() ] = $item;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return $headings;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Compare two lists of thread items, return those in $new but not $old.
|
|
|
|
*
|
|
|
|
* @param ContentThreadItem[][] $old
|
|
|
|
* @param ContentThreadItem[][] $new
|
|
|
|
* @return iterable<ContentThreadItem>
|
|
|
|
*/
|
|
|
|
private static function findAddedItems( array $old, array $new ) {
|
|
|
|
foreach ( $new as $itemName => $nameNewItems ) {
|
|
|
|
// Usually, there will be 0 or 1 $nameNewItems, and 0 $nameOldItems,
|
|
|
|
// 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 items are actually new. See the 'multiple' and 'sametime' test cases.
|
|
|
|
//
|
|
|
|
$nameOldItems = $old[ $itemName ] ?? [];
|
|
|
|
$addedCount = count( $nameNewItems ) - count( $nameOldItems );
|
|
|
|
|
|
|
|
if ( $addedCount > 0 ) {
|
|
|
|
// For any name that occurs more times in new than old, report that many new items,
|
|
|
|
// preferring IDs that did not occur in old, then preferring items lower on the page.
|
|
|
|
foreach ( array_reverse( $nameNewItems ) as $itemId => $newItem ) {
|
|
|
|
if ( $addedCount > 0 && !isset( $nameOldItems[ $itemId ] ) ) {
|
|
|
|
yield $newItem;
|
|
|
|
$addedCount--;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
foreach ( array_reverse( $nameNewItems ) as $itemId => $newItem ) {
|
|
|
|
if ( $addedCount > 0 ) {
|
|
|
|
yield $newItem;
|
|
|
|
$addedCount--;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
Assert::postcondition( $addedCount === 0, 'Reported expected number of items' );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-15 13:56:13 +00:00
|
|
|
/**
|
|
|
|
* Helper for generateEventsForRevision(), separated out for easier testing.
|
|
|
|
*
|
|
|
|
* @param array &$events
|
2022-03-18 03:28:06 +00:00
|
|
|
* @param ContentThreadItemSet $oldItemSet
|
|
|
|
* @param ContentThreadItemSet $newItemSet
|
2021-07-15 13:56:13 +00:00
|
|
|
* @param RevisionRecord $newRevRecord
|
|
|
|
* @param PageIdentity $title
|
|
|
|
* @param UserIdentity $user
|
|
|
|
*/
|
2022-02-19 06:31:34 +00:00
|
|
|
protected static function generateEventsFromItemSets(
|
2021-07-15 13:56:13 +00:00
|
|
|
array &$events,
|
2022-03-18 03:28:06 +00:00
|
|
|
ContentThreadItemSet $oldItemSet,
|
|
|
|
ContentThreadItemSet $newItemSet,
|
2021-07-15 13:56:13 +00:00
|
|
|
RevisionRecord $newRevRecord,
|
|
|
|
PageIdentity $title,
|
|
|
|
UserIdentity $user
|
2021-12-01 14:53:20 +00:00
|
|
|
): void {
|
2022-06-09 13:51:33 +00:00
|
|
|
$newComments = static::groupCommentsByThreadAndName( $newItemSet->getThreadItems() );
|
|
|
|
$oldComments = static::groupCommentsByThreadAndName( $oldItemSet->getThreadItems() );
|
2021-07-16 16:01:18 +00:00
|
|
|
$addedComments = [];
|
|
|
|
foreach ( $newComments as $threadName => $threadNewComments ) {
|
2022-07-30 21:59:55 +00:00
|
|
|
$threadOldComments = $oldComments[ $threadName ] ?? [];
|
|
|
|
foreach ( static::findAddedItems( $threadOldComments, $threadNewComments ) as $newComment ) {
|
|
|
|
Assert::precondition( $newComment instanceof ContentCommentItem, 'Must be ContentCommentItem' );
|
|
|
|
$addedComments[] = $newComment;
|
2021-02-09 21:51:09 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-30 21:59:55 +00:00
|
|
|
$newHeadings = static::groupSubscribableHeadings( $newItemSet->getThreads() );
|
|
|
|
$oldHeadings = static::groupSubscribableHeadings( $oldItemSet->getThreads() );
|
2023-01-31 15:50:34 +00:00
|
|
|
|
|
|
|
$addedHeadings = [];
|
|
|
|
foreach ( static::findAddedItems( $oldHeadings, $newHeadings ) as $newHeading ) {
|
|
|
|
Assert::precondition( $newHeading instanceof ContentHeadingItem, 'Must be ContentHeadingItem' );
|
|
|
|
$addedHeadings[] = $newHeading;
|
|
|
|
}
|
|
|
|
|
2022-07-30 21:59:55 +00:00
|
|
|
$removedHeadings = [];
|
|
|
|
// Pass swapped parameters to findAddedItems() to find *removed* items
|
|
|
|
foreach ( static::findAddedItems( $newHeadings, $oldHeadings ) as $oldHeading ) {
|
|
|
|
Assert::precondition( $oldHeading instanceof ContentHeadingItem, 'Must be ContentHeadingItem' );
|
|
|
|
$removedHeadings[] = $oldHeading;
|
|
|
|
}
|
|
|
|
|
2021-02-09 21:51:09 +00:00
|
|
|
$mentionedUsers = [];
|
2021-04-30 16:07:31 +00:00
|
|
|
foreach ( $events as &$event ) {
|
2021-02-09 21:51:09 +00:00
|
|
|
if ( $event['type'] === 'mention' || $event['type'] === 'mention-summary' ) {
|
2021-04-30 16:07:31 +00:00
|
|
|
// Save mentioned users in our events, so that we can exclude them from our notification,
|
|
|
|
// to avoid duplicate notifications for a single comment.
|
|
|
|
// Array is keyed by user id so we can do a simple array merge.
|
2021-02-09 21:51:09 +00:00
|
|
|
$mentionedUsers += $event['extra']['mentioned-users'];
|
|
|
|
}
|
2021-04-30 16:07:31 +00:00
|
|
|
|
|
|
|
if ( count( $addedComments ) === 1 ) {
|
|
|
|
// If this edit was a new user talk message according to Echo,
|
|
|
|
// and we also found exactly one new comment,
|
|
|
|
// add some extra information to the edit-user-talk event.
|
|
|
|
if ( $event['type'] === 'edit-user-talk' ) {
|
|
|
|
$event['extra'] += [
|
|
|
|
'comment-id' => $addedComments[0]->getId(),
|
|
|
|
'comment-name' => $addedComments[0]->getName(),
|
|
|
|
'content' => $addedComments[0]->getBodyText( true ),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
// Similarly for mentions.
|
|
|
|
// We don't handle 'content' in this case, as Echo makes its own snippets.
|
|
|
|
if ( $event['type'] === 'mention' ) {
|
|
|
|
$event['extra'] += [
|
|
|
|
'comment-id' => $addedComments[0]->getId(),
|
|
|
|
'comment-name' => $addedComments[0]->getName(),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
}
|
2021-02-09 21:51:09 +00:00
|
|
|
}
|
|
|
|
|
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-10-18 08:32:17 +00:00
|
|
|
// For very similar reasons, we do logging here
|
|
|
|
static::logAddedComments( $addedComments, $newRevRecord, $title, $user );
|
2021-07-22 18:05:34 +00:00
|
|
|
}
|
|
|
|
|
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-11-08 18:00:25 +00:00
|
|
|
// Ignore comments which are more than 10 minutes old, as this may be a user archiving
|
|
|
|
// their own comment. (T290803)
|
|
|
|
$revTimestamp = new DateTimeImmutable( $newRevRecord->getTimestamp() );
|
|
|
|
$threshold = $revTimestamp->sub( new DateInterval( 'PT10M' ) );
|
|
|
|
if ( $newComment->getTimestamp() <= $threshold ) {
|
|
|
|
continue;
|
|
|
|
}
|
2021-09-01 22:05:00 +00:00
|
|
|
$heading = $newComment->getSubscribableHeading();
|
|
|
|
if ( !$heading ) {
|
2021-06-25 13:24:46 +00:00
|
|
|
continue;
|
|
|
|
}
|
2021-02-09 21:51:09 +00:00
|
|
|
$events[] = [
|
2023-01-31 15:50:34 +00:00
|
|
|
// This probably should've been called "dt-new-comment": this code is
|
|
|
|
// unaware if there are any subscriptions to the containing topic and
|
|
|
|
// an event is generated for every comment posted.
|
|
|
|
// However, changing this would require a complex migration.
|
2021-02-09 21:51:09 +00:00
|
|
|
'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-08-17 20:23:27 +00:00
|
|
|
|
|
|
|
$titleForSubscriptions = Title::castFromPageIdentity( $title )->createFragmentTarget( $heading->getText() );
|
|
|
|
static::addAutoSubscription( $user, $titleForSubscriptions, $heading->getName() );
|
2021-02-09 21:51:09 +00:00
|
|
|
}
|
2022-07-30 21:59:55 +00:00
|
|
|
|
|
|
|
foreach ( $removedHeadings as $oldHeading ) {
|
|
|
|
$events[] = [
|
|
|
|
'type' => 'dt-removed-topic',
|
|
|
|
'title' => $title,
|
|
|
|
'extra' => [
|
|
|
|
'subscribed-comment-name' => $oldHeading->getName(),
|
|
|
|
'heading-id' => $oldHeading->getId(),
|
|
|
|
'heading-name' => $oldHeading->getName(),
|
|
|
|
'section-title' => $oldHeading->getLinkableTitle(),
|
|
|
|
'revid' => $newRevRecord->getId(),
|
|
|
|
],
|
|
|
|
'agent' => $user,
|
|
|
|
];
|
|
|
|
}
|
2023-01-31 15:50:34 +00:00
|
|
|
|
|
|
|
$titleObj = Title::castFromPageIdentity( $title );
|
|
|
|
if ( $titleObj ) {
|
|
|
|
foreach ( $addedHeadings as $newHeading ) {
|
|
|
|
// Don't use $event here as that already exists as a reference from above
|
|
|
|
$addTopicEvent = [
|
|
|
|
'type' => 'dt-added-topic',
|
|
|
|
'title' => $title,
|
|
|
|
'extra' => [
|
|
|
|
// As no one can be subscribed to a topic before it has been created,
|
|
|
|
// we will notify users who have subscribed to the whole page.
|
|
|
|
'subscribed-comment-name' => CommentUtils::getNewTopicsSubscriptionId( $titleObj ),
|
|
|
|
'heading-id' => $newHeading->getId(),
|
|
|
|
'heading-name' => $newHeading->getName(),
|
|
|
|
'section-title' => $newHeading->getLinkableTitle(),
|
|
|
|
'revid' => $newRevRecord->getId(),
|
|
|
|
],
|
|
|
|
'agent' => $user,
|
|
|
|
];
|
|
|
|
// Add metadata about the accompanying comment
|
|
|
|
$firstComment = $newHeading->getOldestReply();
|
|
|
|
if ( $firstComment ) {
|
|
|
|
$addTopicEvent['extra']['comment-id'] = $firstComment->getId();
|
|
|
|
$addTopicEvent['extra']['comment-name'] = $firstComment->getName();
|
|
|
|
$addTopicEvent['extra']['content'] = $firstComment->getBodyText( true );
|
|
|
|
}
|
|
|
|
$events[] = $addTopicEvent;
|
|
|
|
}
|
|
|
|
}
|
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
|
|
|
|
*/
|
2021-12-01 14:53:20 +00:00
|
|
|
protected static function addCommentChangeTag( RevisionRecord $newRevRecord ): void {
|
2021-07-22 18:05:34 +00:00
|
|
|
// 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-08-17 20:23:27 +00:00
|
|
|
/**
|
|
|
|
* Add an automatic subscription to the given item, assuming the user has automatic subscriptions
|
|
|
|
* enabled.
|
|
|
|
*
|
|
|
|
* @param UserIdentity $user
|
|
|
|
* @param Title $title
|
|
|
|
* @param string $itemName
|
|
|
|
*/
|
2021-12-01 14:53:20 +00:00
|
|
|
protected static function addAutoSubscription( UserIdentity $user, Title $title, string $itemName ): void {
|
2021-08-17 20:23:27 +00:00
|
|
|
$dtConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'discussiontools' );
|
|
|
|
|
|
|
|
if (
|
2021-12-15 16:21:43 +00:00
|
|
|
$dtConfig->get( 'DiscussionToolsAutoTopicSubEditor' ) === 'any' &&
|
2021-08-17 20:23:27 +00:00
|
|
|
HookUtils::shouldAddAutoSubscription( $user, $title )
|
|
|
|
) {
|
|
|
|
$subscriptionStore = MediaWikiServices::getInstance()->getService( 'DiscussionTools.SubscriptionStore' );
|
|
|
|
$subscriptionStore->addAutoSubscriptionForUser( $user, $title, $itemName );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
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-17 20:23:27 +00:00
|
|
|
[ SubscriptionStore::STATE_SUBSCRIBED, SubscriptionStore::STATE_AUTOSUBSCRIBED ]
|
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;
|
|
|
|
}
|
|
|
|
|
2021-10-18 08:32:17 +00:00
|
|
|
/**
|
|
|
|
* Log stuff to EventLogging's Schema:TalkPageEvent
|
|
|
|
* If you don't have EventLogging installed, does nothing.
|
|
|
|
*
|
|
|
|
* @param array $addedComments
|
|
|
|
* @param RevisionRecord $newRevRecord
|
|
|
|
* @param PageIdentity $title
|
|
|
|
* @param UserIdentity $identity
|
|
|
|
* @return bool Whether events were logged
|
|
|
|
*/
|
2021-12-01 14:53:20 +00:00
|
|
|
protected static function logAddedComments(
|
|
|
|
array $addedComments,
|
|
|
|
RevisionRecord $newRevRecord,
|
|
|
|
PageIdentity $title,
|
|
|
|
UserIdentity $identity
|
|
|
|
): bool {
|
2022-07-27 11:39:50 +00:00
|
|
|
global $wgDTSchemaEditAttemptStepOversample, $wgDBname;
|
2021-11-18 17:27:44 +00:00
|
|
|
$context = RequestContext::getMain();
|
|
|
|
$request = $context->getRequest();
|
2021-10-18 08:32:17 +00:00
|
|
|
// We've reached here through Echo's post-save deferredupdate, which
|
|
|
|
// might either be after an API request from DiscussionTools or a
|
|
|
|
// regular POST from WikiEditor. Both should have this value snuck
|
|
|
|
// into their request if their session is being logged.
|
2022-08-26 10:07:15 +00:00
|
|
|
if ( !$request->getCheck( 'editingStatsId' ) ) {
|
2021-10-18 08:32:17 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
$editingStatsId = $request->getVal( 'editingStatsId' );
|
2022-08-26 10:07:15 +00:00
|
|
|
$isDiscussionTools = $request->getCheck( 'dttags' );
|
2021-10-18 08:32:17 +00:00
|
|
|
|
|
|
|
$extensionRegistry = ExtensionRegistry::getInstance();
|
|
|
|
if ( !$extensionRegistry->isLoaded( 'EventLogging' ) ) {
|
|
|
|
return false;
|
|
|
|
}
|
2022-06-09 13:51:33 +00:00
|
|
|
$inSample = static::inEventSample( $editingStatsId );
|
2022-07-27 11:39:50 +00:00
|
|
|
$shouldOversample = ( $isDiscussionTools && $wgDTSchemaEditAttemptStepOversample ) || (
|
2021-11-18 17:27:44 +00:00
|
|
|
$extensionRegistry->isLoaded( 'WikimediaEvents' ) &&
|
|
|
|
// @phan-suppress-next-line PhanUndeclaredClassMethod
|
|
|
|
\WikimediaEvents\WikimediaEventsHooks::shouldSchemaEditAttemptStepOversample( $context )
|
|
|
|
);
|
2021-10-18 08:32:17 +00:00
|
|
|
if ( !$inSample && !$shouldOversample ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2022-08-04 23:43:16 +00:00
|
|
|
$editTracker = MediaWikiServices::getInstance()
|
|
|
|
->getUserEditTracker();
|
2021-10-18 08:32:17 +00:00
|
|
|
|
2022-02-11 21:06:00 +00:00
|
|
|
$commonData = [
|
|
|
|
'$schema' => '/analytics/mediawiki/talk_page_edit/1.1.0',
|
|
|
|
'action' => 'publish',
|
|
|
|
'session_id' => $editingStatsId,
|
|
|
|
'page_id' => $newRevRecord->getPageId(),
|
|
|
|
'page_namespace' => $title->getNamespace(),
|
|
|
|
'revision_id' => $newRevRecord->getId() ?: 0,
|
|
|
|
'performer' => [
|
|
|
|
// Note: we're logging the user who made the edit, not the user who's signed on the comment
|
2022-08-04 23:43:16 +00:00
|
|
|
'user_id' => $identity->getId(),
|
|
|
|
'user_edit_count' => $editTracker->getUserEditCount( $identity ) ?: 0,
|
2022-02-11 21:06:00 +00:00
|
|
|
// Retention-safe values:
|
2022-08-04 23:43:16 +00:00
|
|
|
'user_is_anonymous' => !$identity->isRegistered(),
|
|
|
|
'user_edit_count_bucket' => \UserBucketProvider::getUserEditCountBucket( $identity ) ?: 'N/A',
|
2022-02-11 21:06:00 +00:00
|
|
|
],
|
|
|
|
'database' => $wgDBname,
|
|
|
|
// This is unreliable, but sufficient for our purposes; we
|
|
|
|
// mostly just want to see the difference between DT and
|
|
|
|
// everything-else:
|
|
|
|
'integration' => $isDiscussionTools ? 'discussiontools' : 'page',
|
|
|
|
];
|
|
|
|
|
2021-10-18 08:32:17 +00:00
|
|
|
foreach ( $addedComments as $comment ) {
|
|
|
|
$heading = $comment->getSubscribableHeading();
|
|
|
|
$parent = $comment->getParent();
|
|
|
|
if ( !$heading || !$parent ) {
|
|
|
|
continue;
|
|
|
|
}
|
2022-02-11 21:06:00 +00:00
|
|
|
if ( $parent->getType() === 'heading' ) {
|
|
|
|
if ( count( $heading->getReplies() ) === 1 ) {
|
|
|
|
// A new heading was added when this comment was created
|
|
|
|
$component_type = 'topic';
|
|
|
|
} else {
|
|
|
|
$component_type = 'comment';
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
$component_type = 'response';
|
|
|
|
}
|
2022-03-06 16:10:40 +00:00
|
|
|
EventLogging::submit( 'mediawiki.talk_page_edit', array_merge( $commonData, [
|
2022-02-11 21:06:00 +00:00
|
|
|
'component_type' => $component_type,
|
2021-10-18 08:32:17 +00:00
|
|
|
'topic_id' => $heading->getId(),
|
|
|
|
'comment_id' => $comment->getId(),
|
|
|
|
'comment_parent_id' => $parent->getId(),
|
2022-02-11 21:06:00 +00:00
|
|
|
] ) );
|
2021-10-18 08:32:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Should the current session be sampled for EventLogging?
|
|
|
|
*
|
|
|
|
* @param string $sessionId
|
|
|
|
* @return bool Whether to sample the session
|
|
|
|
*/
|
2021-12-01 14:53:20 +00:00
|
|
|
protected static function inEventSample( string $sessionId ): bool {
|
2021-10-18 08:32:17 +00:00
|
|
|
global $wgDTSchemaEditAttemptStepSamplingRate, $wgWMESchemaEditAttemptStepSamplingRate;
|
|
|
|
// Sample 6.25%
|
|
|
|
$samplingRate = 0.0625;
|
|
|
|
if ( isset( $wgDTSchemaEditAttemptStepSamplingRate ) ) {
|
|
|
|
$samplingRate = $wgDTSchemaEditAttemptStepSamplingRate;
|
|
|
|
}
|
|
|
|
if ( isset( $wgWMESchemaEditAttemptStepSamplingRate ) ) {
|
|
|
|
$samplingRate = $wgWMESchemaEditAttemptStepSamplingRate;
|
|
|
|
}
|
|
|
|
if ( $samplingRate === 0 ) {
|
|
|
|
return false;
|
|
|
|
}
|
2022-03-06 16:10:40 +00:00
|
|
|
$inSample = EventLogging::sessionInSample(
|
2021-10-18 08:32:17 +00:00
|
|
|
(int)( 1 / $samplingRate ), $sessionId
|
|
|
|
);
|
|
|
|
return $inSample;
|
|
|
|
}
|
|
|
|
|
2021-02-09 21:51:09 +00:00
|
|
|
}
|