Comment thanking

Bug: T249893
Change-Id: I64f7abc26bfc3e7b226340934a637a555edf754f
This commit is contained in:
Ed Sanders 2022-01-09 00:50:22 +00:00 committed by Bartosz Dziewoński
parent d1bffdee70
commit a86897f890
14 changed files with 511 additions and 7 deletions

View file

@ -10,6 +10,7 @@ $cfg['directory_list'] = array_merge(
'../../extensions/EventLogging',
'../../extensions/Gadgets',
'../../extensions/BetaFeatures',
'../../extensions/Thanks',
]
);
@ -21,6 +22,7 @@ $cfg['exclude_analysis_directory_list'] = array_merge(
'../../extensions/EventLogging',
'../../extensions/Gadgets',
'../../extensions/BetaFeatures',
'../../extensions/Thanks',
]
);

View file

@ -44,6 +44,7 @@
"topicsubscriptions.js",
"mobile.js",
"overflowMenu.js",
"thanks.js",
"LedeSectionDialog.js",
{
"name": "controller/contLangMessages.json",
@ -154,7 +155,12 @@
"discussiontools-topicsubscription-notify-unsubscribed-body",
"discussiontools-topicsubscription-notify-unsubscribed-title",
"pagetitle",
"skin-view-edit"
"skin-view-edit",
"cancel",
"thanks-button-thank",
"thanks-button-thanked",
"thanks-confirmation2",
"thanks-thanked-notice"
]
},
"ext.discussionTools.minervaicons": {
@ -418,6 +424,7 @@
"RevisionDataUpdates": "dataupdates",
"LoadExtensionSchemaUpdates": "installer",
"GetDoubleUnderscoreIDs": "parser",
"ApiMain::moduleManager": "api",
"ParserAfterTidy": "parser",
"ParserOutputPostCacheTransform": "parser",
"BeforeDisplayNoArticleText": "page",
@ -446,6 +453,9 @@
"installer": {
"class": "MediaWiki\\Extension\\DiscussionTools\\Hooks\\InstallerHooks"
},
"api": {
"class": "MediaWiki\\Extension\\DiscussionTools\\Hooks\\ApiHooks"
},
"page": {
"class": "MediaWiki\\Extension\\DiscussionTools\\Hooks\\PageHooks",
"services": [
@ -561,6 +571,10 @@
"value": true,
"description": "Enable permalinks frontend features: 1. Convert signature timestamps to comment links. 2. Show notification when the target comment is found on another page."
},
"DiscussionToolsEnableThanks": {
"value": true,
"description": "Show a button to thank individual comments. Requires the 'Thanks' extension."
},
"DiscussionToolsAutoTopicSubEditor": {
"value": "any",
"description": "Editor which triggers automatic topic subscriptions. Either 'discussiontoolsapi' for edits made using DiscussionTools' API (e.g. reply and new topic tools), or 'any' for any editor."

View file

@ -38,5 +38,7 @@
"apihelp-discussiontoolssubscribe-param-commentname": "Name of the topic to subscribe to (or unsubscribe from)",
"apihelp-discussiontoolssubscribe-param-page": "A page on which the topic appears",
"apihelp-discussiontoolssubscribe-param-subscribe": "True to subscribe, false to unsubscribe",
"apihelp-discussiontoolssubscribe-summary": "Subscribe (or unsubscribe) to receive notifications about a topic."
"apihelp-discussiontoolssubscribe-summary": "Subscribe (or unsubscribe) to receive notifications about a topic.",
"apihelp-discussiontoolsthank-param-commentid": "ID of the comment to thank.",
"apihelp-discussiontoolsthank-summary": "Send a public thank-you notification for a comment."
}

View file

@ -40,5 +40,7 @@
"apihelp-discussiontoolssubscribe-param-commentname": "{{doc-apihelp-param|discussiontoolssubscribe|commentname}}",
"apihelp-discussiontoolssubscribe-param-page": "{{doc-apihelp-param|discussiontoolssubscribe|page}}",
"apihelp-discussiontoolssubscribe-param-subscribe": "{{doc-apihelp-param|discussiontoolssubscribe|subscribe}}",
"apihelp-discussiontoolssubscribe-summary": "{{doc-apihelp-summary|discussiontoolssubscribe}}"
"apihelp-discussiontoolssubscribe-summary": "{{doc-apihelp-summary|discussiontoolssubscribe}}",
"apihelp-discussiontoolsthank-param-commentid": "{{doc-apihelp-summary|discussiontoolsthank|commentid}}",
"apihelp-discussiontoolsthank-summary": "{{doc-apihelp-summary|discussiontoolsthank}}"
}

View file

@ -71,6 +71,9 @@
"discussiontools-notification-added-topic-header-bundled": "{{PLURAL:$1|One new topic|$1 new topics|100=99+ new topics}} on \"<strong>$2</strong>\".",
"discussiontools-notification-added-topic-header-compact": "$3: <em>$4</em>",
"discussiontools-notification-added-topic-view": "View topic",
"discussiontools-notification-comment-thank-header": "$1 {{GENDER:$2|thanked}} {{GENDER:$4|you}} for your comment in \"<strong>$3</strong>\".",
"discussiontools-notification-comment-thank-header-bundled": "{{PLURAL:$1|One person|$1 people|100=99+ people}} thanked {{GENDER:$3|you}} for your comment in \"<strong>$2</strong>\".",
"discussiontools-notification-comment-thank-header-compact": "$1 {{GENDER:$2|thanked}} {{GENDER:$3|you}}.",
"discussiontools-notification-removed-topic-body": "{{GENDER:|You}} might no longer receive notifications about {{PLURAL:$1|this topic|these topics}}.",
"discussiontools-notification-removed-topic-header": "Topic \"<strong>$4</strong>\" was archived or removed from $3.",
"discussiontools-notification-removed-topic-header-bundled": "{{PLURAL:$1|One topic was|$1 topics were|100=99+ topics were}} archived or removed from $2.",

View file

@ -85,6 +85,9 @@
"discussiontools-notification-added-topic-header-bundled": "Notification header for when multiple new topics have been started on a page.\n\n* $1 - number of new topics.\n* $2 - discussion page title.",
"discussiontools-notification-added-topic-header-compact": "Notification compact header for when a new topic has been started on a page.",
"discussiontools-notification-added-topic-view": "Label for button to view topic that was just posted.",
"discussiontools-notification-comment-thank-header": "Notification header for when you have been thanked for a comment. Parameters:\n* $1 is the username of the user sending the thanks (not suitable for GENDER).\n* $2 is the thanking user's name for use in GENDER.\n* $3 is the topic in which the thanked comment was posted.\n* $4 is the username of the user being thanked, for use in GENDER.",
"discussiontools-notification-comment-thank-header-bundled": "Notification header for when you have received multiple thanks for a comment. Parameters:\n* $1 is the number of users who sent thanks for the same comment. When used with PLURAL, the value 100 represents more than 99.\n* $2 is the topic in which the thanked comment was posted.\n* $3 is the username of the user being thanked, for use in GENDER.",
"discussiontools-notification-comment-thank-header-compact": "Notification compact header for when you have been thanked for a comment. Parameters:\n* $1 is the username of the user sending the thanks (not suitable for GENDER).\n* $2 is the thanking user's name for use in GENDER.\n* $3 is the username of the user being thanked, for use in GENDER.",
"discussiontools-notification-removed-topic-body": "Notification body text for when multiple topics were removed from a page.\n\nFollows {{msg-mw|discussiontools-notification-removed-topic-header}} or {{msg-mw|discussiontools-notification-removed-topic-header-bundled}}.\n\nParameters:\n* $1 - the number of topics removed",
"discussiontools-notification-removed-topic-header": "Notification header text for when a topic was removed from a page. Parameters:\n* $1 - the formatted username of the user who replied to the topic (unused)\n* $2 - the username for gender purposes (unused)\n* $3 - title of the page\n* $4 - title of the topic",
"discussiontools-notification-removed-topic-header-bundled": "Notification header text for when multiple topics were removed from a page. Parameters:\n* $1 - the number of topics removed\n* $2 - title of the page",

View file

@ -0,0 +1,158 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
use ApiBase;
use ApiMain;
use ApiUsageException;
use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
use MediaWiki\Extension\Notifications\Model\Event;
use MediaWiki\Extension\Thanks\Api\ApiThank;
use MediaWiki\Extension\Thanks\Storage\LogStore;
use MediaWiki\Extension\VisualEditor\ApiParsoidTrait;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\User\UserFactory;
use Title;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
/**
* API module to send DiscussionTools comment thanks notifications
*
* @ingroup API
* @ingroup Extensions
*/
class ApiDiscussionToolsThank extends ApiThank {
use ApiDiscussionToolsTrait;
use ApiParsoidTrait;
private RevisionLookup $revisionLookup;
private UserFactory $userFactory;
/**
* @inheritDoc
*/
public function __construct(
ApiMain $main,
$action,
PermissionManager $permissionManager,
LogStore $storage,
RevisionLookup $revisionLookup,
UserFactory $userFactory
) {
parent::__construct( $main, $action, $permissionManager, $storage );
$this->revisionLookup = $revisionLookup;
$this->userFactory = $userFactory;
}
/**
* @inheritDoc
* @throws ApiUsageException
* @throws ResourceLimitExceededException
*/
public function execute() {
$user = $this->getUser();
$this->dieOnBadUser( $user );
$this->dieOnUserBlockedFromThanks( $user );
$params = $this->extractRequestParams();
$title = Title::newFromText( $params['page'] );
$commentId = $params['commentid'];
if ( !$title ) {
$this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
}
// TODO: Using the data in the permalinks database would be much
// faster, we just wouldn't have the comment content.
// Support oldid?
$revision = $this->revisionLookup->getRevisionByTitle( $title );
if ( !$revision ) {
throw ApiUsageException::newWithMessage(
$this,
[ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ],
'nosuchrevid'
);
}
$threadItemSet = HookUtils::parseRevisionParsoidHtml( $revision, __METHOD__ );
$comment = $threadItemSet->findCommentById( $commentId );
if ( !$comment || !( $comment instanceof ContentCommentItem ) ) {
$this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
}
if ( $user->getRequest()->getSessionData( "discussiontools-thanked-{$comment->getId()}" ) ) {
$this->markResultSuccess( $comment->getAuthor() );
return;
}
$uniqueId = "discussiontools-{$comment->getId()}";
// Do one last check to make sure we haven't sent Thanks before
if ( $this->haveAlreadyThanked( $user, $uniqueId ) ) {
// Pretend the thanks were sent
$this->markResultSuccess( $comment->getAuthor() );
return;
}
$recipient = $this->userFactory->newFromName( $comment->getAuthor() );
if ( !$recipient || !$recipient->getId() ) {
$this->dieWithError( 'thanks-error-invalidrecipient', 'invalidrecipient' );
}
$this->dieOnBadRecipient( $user, $recipient );
$heading = $comment->getSubscribableHeading();
if ( !$heading ) {
$heading = $comment->getHeading();
}
// Create the notification via Echo extension
Event::create( [
'type' => 'dt-thank',
'title' => $title,
'extra' => [
'comment-id' => $comment->getId(),
'comment-name' => $comment->getName(),
'content' => $comment->getBodyText( true ),
'section-title' => $heading->getLinkableTitle(),
'thanked-user-id' => $recipient->getId(),
'revid' => $revision->getId(),
],
'agent' => $user,
] );
// And mark the thank in session for a cheaper check to prevent duplicates (T48690).
$user->getRequest()->setSessionData( "discussiontools-thanked-{$comment->getId()}", true );
// Set success message.
$this->markResultSuccess( $recipient->getName() );
$this->logThanks( $user, $recipient, $uniqueId );
}
/**
* @inheritDoc
*/
public function getAllowedParams() {
return [
'page' => [
ParamValidator::PARAM_REQUIRED => true,
// Message will exist if DiscussionTools is installed as VE is a dependency
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-page',
],
'commentid' => [
ParamValidator::PARAM_REQUIRED => true,
ParamValidator::PARAM_TYPE => 'string',
],
'token' => [
ParamValidator::PARAM_REQUIRED => true,
ParamValidator::PARAM_TYPE => 'string',
],
];
}
}

View file

@ -0,0 +1,43 @@
<?php
/**
* DiscussionTools API hooks
*
* @file
* @ingroup Extensions
* @license MIT
*/
namespace MediaWiki\Extension\DiscussionTools\Hooks;
use ApiModuleManager;
use ExtensionRegistry;
use MediaWiki\Api\Hook\ApiMain__moduleManagerHook;
use MediaWiki\Extension\DiscussionTools\ApiDiscussionToolsThank;
// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
class ApiHooks implements
ApiMain__moduleManagerHook
{
/**
* @param ApiModuleManager $moduleManager
* @return bool|void
*/
public function onApiMain__moduleManager( $moduleManager ) {
if ( ExtensionRegistry::getInstance()->isLoaded( 'Thanks' ) ) {
$moduleManager->addModule(
'discussiontoolsthank',
'action',
[
'class' => ApiDiscussionToolsThank::class,
'services' => [
'PermissionManager',
'ThanksLogStore',
'RevisionLookup',
'UserFactory',
]
]
);
}
}
}

View file

@ -9,8 +9,11 @@
namespace MediaWiki\Extension\DiscussionTools\Hooks;
use ExtensionRegistry;
use IContextSource;
use MediaWiki\Extension\DiscussionTools\OverflowMenuItem;
use MediaWiki\MediaWikiServices;
use MediaWiki\User\UserNameUtils;
class DiscussionToolsHooks implements
DiscussionToolsAddOverflowMenuItemsHook
@ -41,5 +44,26 @@ class DiscussionToolsHooks implements
2
);
}
$dtConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'discussiontools' );
if ( $dtConfig->get( 'DiscussionToolsEnableThanks' ) ) {
$user = $contextSource->getUser();
$showThanks = ExtensionRegistry::getInstance()->isLoaded( 'Thanks' );
if ( $showThanks && ( $threadItemData['type'] ?? null ) === 'comment' && $user->isNamed() ) {
$userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();
$recipient = $userNameUtils->getCanonical( $threadItemData['author'], UserNameUtils::RIGOR_NONE );
if (
$recipient !== $user->getName() &&
!$userNameUtils->isIP( $recipient )
) {
$overflowMenuItems[] = new OverflowMenuItem(
'thank',
'heart',
$contextSource->msg( 'thanks-button-thank' ),
);
}
}
}
}
}

View file

@ -9,7 +9,9 @@
namespace MediaWiki\Extension\DiscussionTools\Hooks;
use ExtensionRegistry;
use MediaWiki\Extension\DiscussionTools\Notifications\AddedTopicPresentationModel;
use MediaWiki\Extension\DiscussionTools\Notifications\CommentThanksPresentationModel;
use MediaWiki\Extension\DiscussionTools\Notifications\EnhancedEchoEditUserTalkPresentationModel;
use MediaWiki\Extension\DiscussionTools\Notifications\EnhancedEchoMentionPresentationModel;
use MediaWiki\Extension\DiscussionTools\Notifications\EventDispatcher;
@ -101,6 +103,25 @@ class EchoHooks implements
],
];
if ( ExtensionRegistry::getInstance()->isLoaded( 'Thanks' ) ) {
$notifications['dt-thank'] = [
'category' => 'edit-thank',
'group' => 'positive',
'section' => 'message',
'user-locators' => [
[
[ UserLocator::class, 'locateFromEventExtra' ],
[ 'thanked-user-id' ]
]
],
'presentation-model' => CommentThanksPresentationModel::class,
'bundle' => [
'web' => true,
'expandable' => true,
],
];
}
// Override default handlers
$notifications['edit-user-talk']['presentation-model'] = EnhancedEchoEditUserTalkPresentationModel::class;
$notifications['mention']['presentation-model'] = EnhancedEchoMentionPresentationModel::class;
@ -116,6 +137,9 @@ class EchoHooks implements
$bundleString = $event->getType() . '-' . $event->getTitle()->getNamespace()
. '-' . $event->getTitle()->getDBkey();
break;
case 'dt-thank':
$bundleString = $event->getType() . '-' . $event->getExtraParam( 'comment-name' );
break;
}
}

View file

@ -304,12 +304,13 @@ class PageHooks implements
// Reply button: share
$output->addModuleStyles( 'oojs-ui.styles.icons-content' );
}
$output->addModuleStyles( [
// Overflow menu ('ellipsis' icon)
'oojs-ui.styles.icons-interactions',
] );
if ( $isMobile ) {
$output->addModuleStyles( [
// Mobile overflow menu:
// ellipsis
'oojs-ui.styles.icons-interactions',
// edit
// Edit button in overflow menu ('edit' icon)
'oojs-ui.styles.icons-editing-core',
] );
}

View file

@ -0,0 +1,150 @@
<?php
/**
* EchoEventPresentationModel for comment thanks notifications
*
* @file
* @ingroup Extensions
* @license MIT
*/
namespace MediaWiki\Extension\DiscussionTools\Notifications;
use Language;
use MediaWiki\Extension\Notifications\Formatters\EchoEventPresentationModel;
use MediaWiki\Extension\Notifications\Formatters\EchoPresentationModelSection;
use MediaWiki\Extension\Notifications\Model\Event;
use MediaWiki\Revision\RevisionRecord;
use Message;
use RawMessage;
use User;
class CommentThanksPresentationModel extends EchoEventPresentationModel {
use DiscussionToolsEventTrait;
/**
* @var EchoPresentationModelSection
*/
private $section;
/**
* @inheritDoc
*/
protected function __construct( Event $event, Language $language, User $user, $distributionType ) {
parent::__construct( $event, $language, $user, $distributionType );
$this->section = new EchoPresentationModelSection( $event, $user, $language );
}
/**
* @inheritDoc
*/
public function getIconType() {
return 'thanks';
}
/**
* @inheritDoc
*/
public function canRender() {
return (bool)$this->event->getTitle();
}
/**
* @inheritDoc
*/
public function getPrimaryLink() {
return [
'url' => $this->getCommentLink() ?: $this->event->getTitle()->getFullURL(),
'label' => $this->msg( 'discussiontools-notification-subscribed-new-comment-view' )->text()
];
}
/**
* Get a link to the individual comment, if available.
*
* @return string|null Full URL linking to the comment, null if not available
*/
protected function getCommentLink(): ?string {
if ( !$this->userCan( RevisionRecord::DELETED_TEXT ) ) {
return null;
}
// Thanks notifications are bundled by comment-id, so the link will always be to a single comment
// (unlike in DiscussionToolsEventTrait)
$commentId = $this->event->getExtraParam( 'comment-id' );
if ( !$commentId ) {
return null;
}
$title = $this->event->getTitle();
return $title->createFragmentTarget( $commentId )->getFullURL();
}
/**
* @inheritDoc
*/
protected function getHeaderMessageKey() {
if ( $this->isBundled() ) {
return 'discussiontools-notification-comment-thank-header-bundled';
} else {
return 'discussiontools-notification-comment-thank-header';
}
}
/**
* @inheritDoc
*/
public function getHeaderMessage() {
$title = $this->section->getTruncatedSectionTitle();
if ( !$title ) {
// Comment could have been at the top of the page before
// any section titles. Use the page title instead.
$title = $this->event->getTitle()->getPrefixedText();
}
if ( $this->isBundled() ) {
$count = $this->getNotificationCountForOutput();
$msg = $this->msg( $this->getHeaderMessageKey() );
// Params 1, 2, 3:
$msg->numParams( $count );
$msg->plaintextParams( $title );
$msg->params( $this->getViewingUserForGender() );
return $msg;
} else {
$msg = parent::getHeaderMessage();
// Params 3, 4:
$msg->plaintextParams( $title );
$msg->params( $this->getViewingUserForGender() );
return $msg;
}
}
/**
* @inheritDoc
*/
public function getCompactHeaderMessage() {
$msg = $this->getMessageWithAgent( 'discussiontools-notification-comment-thank-header-compact' );
// Param 3:
$msg->params( $this->getViewingUserForGender() );
return $msg;
}
/**
* @inheritDoc
*/
public function getBodyMessage() {
if ( !$this->isBundled() ) {
return new RawMessage( '$1', [ Message::plaintextParam( $this->getContentSnippet() ) ] );
}
}
/**
* @inheritDoc
*/
public function getSecondaryLinks() {
$pageLink = $this->getPageLink( $this->event->getTitle(), '', true );
if ( $this->isBundled() ) {
return [ $pageLink ];
} else {
return [ $this->getAgentLink(), $pageLink ];
}
}
}

View file

@ -27,6 +27,7 @@ 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' ) );

77
modules/thanks.js Normal file
View file

@ -0,0 +1,77 @@
const cacheKey = 'dt-thanks';
/**
* Thank a comment item
*
* @param {CommentItem} commentItem Comment item
* @return {jQuery.Promise} Resolves when thanks successfully sent, rejects on error
*/
function thankComment( commentItem ) {
// TODO: Add recipient gender for messages
const recipientGender = 'unknown';
return OO.ui.confirm( mw.msg( 'thanks-confirmation2', mw.user ), {
actions: [
{
action: 'accept',
label: mw.msg( 'thanks-button-thank', mw.user, recipientGender ),
flags: [ 'primary', 'progressive' ]
},
{
action: 'cancel',
label: mw.msg( 'cancel' ),
flags: 'safe'
}
]
} ).then( ( confirmed ) => {
if ( !confirmed ) {
return $.Deferred().reject().promise();
}
const api = require( './controller.js' ).getApi();
return api.postWithToken( 'csrf', {
action: 'discussiontoolsthank',
// We don't need to store the correct transcluded comment page
// for a thank, any page the comment appears on will do.
page: mw.config.get( 'wgRelevantPageName' ),
commentid: commentItem.id
} ).then( () => {
mw.notify( mw.msg( 'thanks-thanked-notice', commentItem.author, recipientGender, mw.user ), { type: 'success' } );
cacheThanked( commentItem );
}, ( code, data ) => {
mw.notify( api.getErrorMessage( data ), { type: 'error' } );
return $.Deferred().reject().promise();
} );
} );
}
function isThanked( threadItem ) {
const cache = mw.storage.getObject( cacheKey ) || {};
return cache[ threadItem.id ];
}
function cacheThanked( threadItem ) {
const cache = mw.storage.getObject( cacheKey ) || {};
cache[ threadItem.id ] = true;
mw.storage.setObject( cacheKey, cache );
}
mw.hook( 'discussionToolsOverflowMenuOnChoose' ).add( ( id, menuItem, threadItem ) => {
// TODO: Add recipient gender for messages
const recipientGender = 'unknown';
if ( id === 'thank' ) {
thankComment( threadItem ).then( () => {
menuItem.setLabel( mw.msg( 'thanks-button-thanked', mw.user, recipientGender ) );
menuItem.setDisabled( true );
} );
}
} );
mw.hook( 'discussionToolsOverflowMenuOnAddItem' ).add( ( id, menuItem, threadItem ) => {
// TODO: Add recipient gender for messages
const recipientGender = 'unknown';
if ( id === 'thank' && isThanked( threadItem ) ) {
menuItem.setLabel( mw.msg( 'thanks-button-thanked', mw.user, recipientGender ) );
menuItem.setDisabled( true );
}
} );