diff --git a/.phan/config.php b/.phan/config.php index 263ba3d4b..b02f7ccf8 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -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', ] ); diff --git a/extension.json b/extension.json index 441f8fcfb..f6c857def 100644 --- a/extension.json +++ b/extension.json @@ -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." diff --git a/i18n/api/en.json b/i18n/api/en.json index 040533a33..4257b4bc8 100644 --- a/i18n/api/en.json +++ b/i18n/api/en.json @@ -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." } diff --git a/i18n/api/qqq.json b/i18n/api/qqq.json index 84bb896c5..616a8c400 100644 --- a/i18n/api/qqq.json +++ b/i18n/api/qqq.json @@ -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}}" } diff --git a/i18n/en.json b/i18n/en.json index 8234ec364..7d5e0a788 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -71,6 +71,9 @@ "discussiontools-notification-added-topic-header-bundled": "{{PLURAL:$1|One new topic|$1 new topics|100=99+ new topics}} on \"$2\".", "discussiontools-notification-added-topic-header-compact": "$3: $4", "discussiontools-notification-added-topic-view": "View topic", + "discussiontools-notification-comment-thank-header": "$1 {{GENDER:$2|thanked}} {{GENDER:$4|you}} for your comment in \"$3\".", + "discussiontools-notification-comment-thank-header-bundled": "{{PLURAL:$1|One person|$1 people|100=99+ people}} thanked {{GENDER:$3|you}} for your comment in \"$2\".", + "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 \"$4\" 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.", diff --git a/i18n/qqq.json b/i18n/qqq.json index 08ea287e4..2a68222af 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -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", diff --git a/includes/ApiDiscussionToolsThank.php b/includes/ApiDiscussionToolsThank.php new file mode 100644 index 000000000..18765607e --- /dev/null +++ b/includes/ApiDiscussionToolsThank.php @@ -0,0 +1,158 @@ +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', + ], + ]; + } +} diff --git a/includes/Hooks/ApiHooks.php b/includes/Hooks/ApiHooks.php new file mode 100644 index 000000000..f5921dce5 --- /dev/null +++ b/includes/Hooks/ApiHooks.php @@ -0,0 +1,43 @@ +isLoaded( 'Thanks' ) ) { + $moduleManager->addModule( + 'discussiontoolsthank', + 'action', + [ + 'class' => ApiDiscussionToolsThank::class, + 'services' => [ + 'PermissionManager', + 'ThanksLogStore', + 'RevisionLookup', + 'UserFactory', + ] + ] + ); + } + } +} diff --git a/includes/Hooks/DiscussionToolsHooks.php b/includes/Hooks/DiscussionToolsHooks.php index 7e1da14c3..6d6988d1b 100644 --- a/includes/Hooks/DiscussionToolsHooks.php +++ b/includes/Hooks/DiscussionToolsHooks.php @@ -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' ), + ); + } + } + } } } diff --git a/includes/Hooks/EchoHooks.php b/includes/Hooks/EchoHooks.php index 74d5b5520..c646224e7 100644 --- a/includes/Hooks/EchoHooks.php +++ b/includes/Hooks/EchoHooks.php @@ -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; } } diff --git a/includes/Hooks/PageHooks.php b/includes/Hooks/PageHooks.php index 84bad781f..59c991155 100644 --- a/includes/Hooks/PageHooks.php +++ b/includes/Hooks/PageHooks.php @@ -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', ] ); } diff --git a/includes/Notifications/CommentThanksPresentationModel.php b/includes/Notifications/CommentThanksPresentationModel.php new file mode 100644 index 000000000..dcb08e27e --- /dev/null +++ b/includes/Notifications/CommentThanksPresentationModel.php @@ -0,0 +1,150 @@ +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 ]; + } + } +} diff --git a/modules/controller.js b/modules/controller.js index ed007e7e4..3d9cefe4c 100644 --- a/modules/controller.js +++ b/modules/controller.js @@ -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' ) ); diff --git a/modules/thanks.js b/modules/thanks.js new file mode 100644 index 000000000..8f0bc0b67 --- /dev/null +++ b/modules/thanks.js @@ -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 ); + } +} );