diff --git a/ts/WoltLabSuite/Core/Ui/Like/Handler.ts b/ts/WoltLabSuite/Core/Ui/Like/Handler.ts deleted file mode 100644 index 97cea9213b8..00000000000 --- a/ts/WoltLabSuite/Core/Ui/Like/Handler.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Provides interface elements to display and review likes. - * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @deprecated 5.2 use ReactionHandler instead - */ - -import * as Core from "../../Core"; -import DomChangeListener from "../../Dom/Change/Listener"; -import * as Language from "../../Language"; -import * as StringUtil from "../../StringUtil"; -import UiReactionHandler from "../Reaction/Handler"; -import User from "../../User"; - -interface LikeHandlerOptions { - // settings - badgeClassNames: string; - isSingleItem: boolean; - markListItemAsActive: boolean; - renderAsButton: boolean; - summaryPrepend: boolean; - summaryUseIcon: boolean; - - // permissions - canDislike: boolean; - canLike: boolean; - canLikeOwnContent: boolean; - canViewSummary: boolean; - - // selectors - badgeContainerSelector: string; - buttonAppendToSelector: string; - buttonBeforeSelector: string; - containerSelector: string; - summarySelector: string; -} - -interface LikeUsers { - [key: string]: number; -} - -interface ElementData { - badge: HTMLUListElement | null; - dislikeButton: null; - likeButton: HTMLAnchorElement | null; - summary: null; - - dislikes: number; - liked: number; - likes: number; - objectId: number; - users: LikeUsers; -} - -const availableReactions = new Map(Object.entries(window.REACTION_TYPES)); - -class UiLikeHandler { - protected readonly _containers = new WeakMap(); - protected readonly _objectType: string; - protected readonly _options: LikeHandlerOptions; - - /** - * Initializes the like handler. - */ - constructor(objectType: string, opts: Partial) { - if (!opts.containerSelector) { - throw new Error( - "[WoltLabSuite/Core/Ui/Like/Handler] Expected a non-empty string for option 'containerSelector'.", - ); - } - - this._objectType = objectType; - this._options = Core.extend( - { - // settings - badgeClassNames: "", - isSingleItem: false, - markListItemAsActive: false, - renderAsButton: true, - summaryPrepend: true, - summaryUseIcon: true, - - // permissions - canDislike: false, - canLike: false, - canLikeOwnContent: false, - canViewSummary: false, - - // selectors - badgeContainerSelector: ".messageHeader .messageStatus", - buttonAppendToSelector: ".messageFooter .messageFooterButtons", - buttonBeforeSelector: "", - containerSelector: "", - summarySelector: ".messageFooterGroup", - }, - opts, - ) as LikeHandlerOptions; - - this.initContainers(); - - DomChangeListener.add(`WoltLabSuite/Core/Ui/Like/Handler-${objectType}`, () => this.initContainers()); - - new UiReactionHandler(this._objectType, { - containerSelector: this._options.containerSelector, - }); - } - - /** - * Initializes all applicable containers. - */ - initContainers(): void { - let triggerChange = false; - - document.querySelectorAll(this._options.containerSelector).forEach((element: HTMLElement) => { - if (this._containers.has(element)) { - return; - } - - const elementData = { - badge: null, - dislikeButton: null, - likeButton: null, - summary: null, - - dislikes: ~~element.dataset.likeDislikes!, - liked: ~~element.dataset.likeLiked!, - likes: ~~element.dataset.likeLikes!, - objectId: ~~element.dataset.objectId!, - users: JSON.parse(element.dataset.likeUsers!), - }; - - this._containers.set(element, elementData); - this._buildWidget(element, elementData); - - triggerChange = true; - }); - - if (triggerChange) { - DomChangeListener.trigger(); - } - } - - /** - * Creates the interface elements. - */ - protected _buildWidget(element: HTMLElement, elementData: ElementData): void { - let badgeContainer: HTMLElement | null; - let isSummaryPosition = true; - - if (this._options.isSingleItem) { - badgeContainer = document.querySelector(this._options.summarySelector); - } else { - badgeContainer = element.querySelector(this._options.summarySelector); - } - - if (badgeContainer === null) { - if (this._options.isSingleItem) { - badgeContainer = document.querySelector(this._options.badgeContainerSelector); - } else { - badgeContainer = element.querySelector(this._options.badgeContainerSelector); - } - - isSummaryPosition = false; - } - - if (badgeContainer !== null) { - const summaryList = document.createElement("ul"); - summaryList.classList.add("reactionSummaryList"); - if (isSummaryPosition) { - summaryList.classList.add("likesSummary"); - } else { - summaryList.classList.add("reactionSummaryListTiny"); - } - - Object.entries(elementData.users).forEach(([reactionTypeId, count]) => { - const reaction = availableReactions.get(reactionTypeId); - if (reactionTypeId === "reactionTypeID" || !reaction) { - return; - } - - // create element - const createdElement = document.createElement("li"); - createdElement.className = "reactCountButton"; - createdElement.setAttribute("reaction-type-id", reactionTypeId); - - const countSpan = document.createElement("span"); - countSpan.className = "reactionCount"; - countSpan.innerHTML = StringUtil.shortUnit(~~count); - createdElement.appendChild(countSpan); - - createdElement.innerHTML = reaction.renderedIcon + createdElement.innerHTML; - - summaryList.appendChild(createdElement); - }); - - if (isSummaryPosition) { - if (this._options.summaryPrepend) { - badgeContainer.insertAdjacentElement("afterbegin", summaryList); - } else { - badgeContainer.insertAdjacentElement("beforeend", summaryList); - } - } else { - if (badgeContainer.nodeName === "OL" || badgeContainer.nodeName === "UL") { - const listItem = document.createElement("li"); - listItem.appendChild(summaryList); - badgeContainer.appendChild(listItem); - } else { - badgeContainer.appendChild(summaryList); - } - } - - elementData.badge = summaryList; - } - - // build reaction button - if (this._options.canLike && (User.userId != ~~element.dataset.userId! || this._options.canLikeOwnContent)) { - let appendTo: HTMLElement | null = null; - if (this._options.buttonAppendToSelector) { - if (this._options.isSingleItem) { - appendTo = document.querySelector(this._options.buttonAppendToSelector); - } else { - appendTo = element.querySelector(this._options.buttonAppendToSelector); - } - } - - let insertPosition: HTMLElement | null = null; - if (this._options.buttonBeforeSelector) { - if (this._options.isSingleItem) { - insertPosition = document.querySelector(this._options.buttonBeforeSelector); - } else { - insertPosition = element.querySelector(this._options.buttonBeforeSelector); - } - } - - if (insertPosition === null && appendTo === null) { - throw new Error("Unable to find insert location for like/dislike buttons."); - } else { - elementData.likeButton = this._createButton( - element, - elementData.users.reactionTypeID, - insertPosition, - appendTo, - ); - } - } - } - - /** - * Creates a reaction button. - */ - protected _createButton( - element: HTMLElement, - reactionTypeID: number, - insertBefore: HTMLElement | null, - appendTo: HTMLElement | null, - ): HTMLAnchorElement { - const title = Language.get("wcf.reactions.react"); - - const listItem = document.createElement("li"); - listItem.className = "wcfReactButton"; - - const button = document.createElement("a"); - button.className = "jsTooltip reactButton"; - if (this._options.renderAsButton) { - button.classList.add("button"); - } - - button.href = "#"; - button.title = title; - - const icon = document.createElement("fa-icon"); - icon.setIcon("smile"); - - if (reactionTypeID === undefined || reactionTypeID == 0) { - icon.dataset.reactionTypeId = "0"; - } else { - button.dataset.reactionTypeId = reactionTypeID.toString(); - button.classList.add("active"); - } - - button.appendChild(icon); - - const invisibleText = document.createElement("span"); - invisibleText.className = "invisible"; - invisibleText.innerHTML = title; - - button.appendChild(document.createTextNode(" ")); - button.appendChild(invisibleText); - - listItem.appendChild(button); - - if (insertBefore) { - insertBefore.insertAdjacentElement("beforebegin", listItem); - } else { - appendTo!.insertAdjacentElement("beforeend", listItem); - } - - return button; - } -} - -export = UiLikeHandler; diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index 944962eee2f..48a2306446f 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -9,8 +9,11 @@ */ use wcf\system\database\table\column\DefaultFalseBooleanDatabaseTableColumn; +use wcf\system\database\table\column\JsonDatabaseTableColumn; +use wcf\system\database\table\column\MediumintDatabaseTableColumn; use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn; use wcf\system\database\table\column\SmallintDatabaseTableColumn; +use wcf\system\database\table\column\TextDatabaseTableColumn; use wcf\system\database\table\PartialDatabaseTable; return [ @@ -25,4 +28,14 @@ ->notNull() ->defaultValue(1), ]), + PartialDatabaseTable::create('wcf1_like_object') + ->columns([ + TextDatabaseTableColumn::create('cachedUsers') + ->drop(), + MediumintDatabaseTableColumn::create('dislikes') + ->notNull() + ->defaultValue(0) + ->drop(), + JsonDatabaseTableColumn::create('cachedReactions'), + ]) ]; diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_likeObject.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_likeObject.php new file mode 100644 index 00000000000..9c039bc633a --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_likeObject.php @@ -0,0 +1,9 @@ +prepare($sql); +$statement->execute(); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Like/Handler.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Like/Handler.js deleted file mode 100644 index 27c7ca80fd1..00000000000 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Like/Handler.js +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Provides interface elements to display and review likes. - * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @deprecated 5.2 use ReactionHandler instead - */ -define(["require", "exports", "tslib", "../../Core", "../../Dom/Change/Listener", "../../Language", "../../StringUtil", "../Reaction/Handler", "../../User"], function (require, exports, tslib_1, Core, Listener_1, Language, StringUtil, Handler_1, User_1) { - "use strict"; - Core = tslib_1.__importStar(Core); - Listener_1 = tslib_1.__importDefault(Listener_1); - Language = tslib_1.__importStar(Language); - StringUtil = tslib_1.__importStar(StringUtil); - Handler_1 = tslib_1.__importDefault(Handler_1); - User_1 = tslib_1.__importDefault(User_1); - const availableReactions = new Map(Object.entries(window.REACTION_TYPES)); - class UiLikeHandler { - _containers = new WeakMap(); - _objectType; - _options; - /** - * Initializes the like handler. - */ - constructor(objectType, opts) { - if (!opts.containerSelector) { - throw new Error("[WoltLabSuite/Core/Ui/Like/Handler] Expected a non-empty string for option 'containerSelector'."); - } - this._objectType = objectType; - this._options = Core.extend({ - // settings - badgeClassNames: "", - isSingleItem: false, - markListItemAsActive: false, - renderAsButton: true, - summaryPrepend: true, - summaryUseIcon: true, - // permissions - canDislike: false, - canLike: false, - canLikeOwnContent: false, - canViewSummary: false, - // selectors - badgeContainerSelector: ".messageHeader .messageStatus", - buttonAppendToSelector: ".messageFooter .messageFooterButtons", - buttonBeforeSelector: "", - containerSelector: "", - summarySelector: ".messageFooterGroup", - }, opts); - this.initContainers(); - Listener_1.default.add(`WoltLabSuite/Core/Ui/Like/Handler-${objectType}`, () => this.initContainers()); - new Handler_1.default(this._objectType, { - containerSelector: this._options.containerSelector, - }); - } - /** - * Initializes all applicable containers. - */ - initContainers() { - let triggerChange = false; - document.querySelectorAll(this._options.containerSelector).forEach((element) => { - if (this._containers.has(element)) { - return; - } - const elementData = { - badge: null, - dislikeButton: null, - likeButton: null, - summary: null, - dislikes: ~~element.dataset.likeDislikes, - liked: ~~element.dataset.likeLiked, - likes: ~~element.dataset.likeLikes, - objectId: ~~element.dataset.objectId, - users: JSON.parse(element.dataset.likeUsers), - }; - this._containers.set(element, elementData); - this._buildWidget(element, elementData); - triggerChange = true; - }); - if (triggerChange) { - Listener_1.default.trigger(); - } - } - /** - * Creates the interface elements. - */ - _buildWidget(element, elementData) { - let badgeContainer; - let isSummaryPosition = true; - if (this._options.isSingleItem) { - badgeContainer = document.querySelector(this._options.summarySelector); - } - else { - badgeContainer = element.querySelector(this._options.summarySelector); - } - if (badgeContainer === null) { - if (this._options.isSingleItem) { - badgeContainer = document.querySelector(this._options.badgeContainerSelector); - } - else { - badgeContainer = element.querySelector(this._options.badgeContainerSelector); - } - isSummaryPosition = false; - } - if (badgeContainer !== null) { - const summaryList = document.createElement("ul"); - summaryList.classList.add("reactionSummaryList"); - if (isSummaryPosition) { - summaryList.classList.add("likesSummary"); - } - else { - summaryList.classList.add("reactionSummaryListTiny"); - } - Object.entries(elementData.users).forEach(([reactionTypeId, count]) => { - const reaction = availableReactions.get(reactionTypeId); - if (reactionTypeId === "reactionTypeID" || !reaction) { - return; - } - // create element - const createdElement = document.createElement("li"); - createdElement.className = "reactCountButton"; - createdElement.setAttribute("reaction-type-id", reactionTypeId); - const countSpan = document.createElement("span"); - countSpan.className = "reactionCount"; - countSpan.innerHTML = StringUtil.shortUnit(~~count); - createdElement.appendChild(countSpan); - createdElement.innerHTML = reaction.renderedIcon + createdElement.innerHTML; - summaryList.appendChild(createdElement); - }); - if (isSummaryPosition) { - if (this._options.summaryPrepend) { - badgeContainer.insertAdjacentElement("afterbegin", summaryList); - } - else { - badgeContainer.insertAdjacentElement("beforeend", summaryList); - } - } - else { - if (badgeContainer.nodeName === "OL" || badgeContainer.nodeName === "UL") { - const listItem = document.createElement("li"); - listItem.appendChild(summaryList); - badgeContainer.appendChild(listItem); - } - else { - badgeContainer.appendChild(summaryList); - } - } - elementData.badge = summaryList; - } - // build reaction button - if (this._options.canLike && (User_1.default.userId != ~~element.dataset.userId || this._options.canLikeOwnContent)) { - let appendTo = null; - if (this._options.buttonAppendToSelector) { - if (this._options.isSingleItem) { - appendTo = document.querySelector(this._options.buttonAppendToSelector); - } - else { - appendTo = element.querySelector(this._options.buttonAppendToSelector); - } - } - let insertPosition = null; - if (this._options.buttonBeforeSelector) { - if (this._options.isSingleItem) { - insertPosition = document.querySelector(this._options.buttonBeforeSelector); - } - else { - insertPosition = element.querySelector(this._options.buttonBeforeSelector); - } - } - if (insertPosition === null && appendTo === null) { - throw new Error("Unable to find insert location for like/dislike buttons."); - } - else { - elementData.likeButton = this._createButton(element, elementData.users.reactionTypeID, insertPosition, appendTo); - } - } - } - /** - * Creates a reaction button. - */ - _createButton(element, reactionTypeID, insertBefore, appendTo) { - const title = Language.get("wcf.reactions.react"); - const listItem = document.createElement("li"); - listItem.className = "wcfReactButton"; - const button = document.createElement("a"); - button.className = "jsTooltip reactButton"; - if (this._options.renderAsButton) { - button.classList.add("button"); - } - button.href = "#"; - button.title = title; - const icon = document.createElement("fa-icon"); - icon.setIcon("smile"); - if (reactionTypeID === undefined || reactionTypeID == 0) { - icon.dataset.reactionTypeId = "0"; - } - else { - button.dataset.reactionTypeId = reactionTypeID.toString(); - button.classList.add("active"); - } - button.appendChild(icon); - const invisibleText = document.createElement("span"); - invisibleText.className = "invisible"; - invisibleText.innerHTML = title; - button.appendChild(document.createTextNode(" ")); - button.appendChild(invisibleText); - listItem.appendChild(button); - if (insertBefore) { - insertBefore.insertAdjacentElement("beforebegin", listItem); - } - else { - appendTo.insertAdjacentElement("beforeend", listItem); - } - return button; - } - } - return UiLikeHandler; -}); diff --git a/wcfsetup/install/files/lib/command/comment/DeleteComments.class.php b/wcfsetup/install/files/lib/command/comment/DeleteComments.class.php index e1d57dcffeb..17e502021ad 100644 --- a/wcfsetup/install/files/lib/command/comment/DeleteComments.class.php +++ b/wcfsetup/install/files/lib/command/comment/DeleteComments.class.php @@ -10,10 +10,10 @@ use wcf\system\comment\CommentHandler; use wcf\system\comment\manager\ICommentManager; use wcf\command\comment\response\DeleteResponses; +use wcf\command\reaction\DeleteObjectReactions; use wcf\system\event\EventHandler; use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; use wcf\system\moderation\queue\ModerationQueueManager; -use wcf\system\reaction\ReactionHandler; use wcf\system\user\activity\event\UserActivityEventHandler; use wcf\system\user\notification\UserNotificationHandler; @@ -89,13 +89,13 @@ private function deleteNotifications(): void private function deleteReactions(): void { - ReactionHandler::getInstance()->removeReactions( + (new DeleteObjectReactions( 'com.woltlab.wcf.comment', $this->commentIDs, UserNotificationHandler::getInstance()->getObjectTypeID($this->objectType->objectType . '.like.notification') ? [$this->objectType->objectType . '.like.notification'] : [] - ); + ))(); } private function deleteResponses(): void diff --git a/wcfsetup/install/files/lib/command/comment/response/DeleteResponses.class.php b/wcfsetup/install/files/lib/command/comment/response/DeleteResponses.class.php index 028defc1ad8..361d3e738e2 100644 --- a/wcfsetup/install/files/lib/command/comment/response/DeleteResponses.class.php +++ b/wcfsetup/install/files/lib/command/comment/response/DeleteResponses.class.php @@ -2,6 +2,7 @@ namespace wcf\command\comment\response; +use wcf\command\reaction\DeleteObjectReactions; use wcf\data\comment\CommentEditor; use wcf\data\comment\CommentList; use wcf\data\comment\response\CommentResponse; @@ -13,7 +14,6 @@ use wcf\system\event\EventHandler; use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; use wcf\system\moderation\queue\ModerationQueueManager; -use wcf\system\reaction\ReactionHandler; use wcf\system\user\activity\event\UserActivityEventHandler; use wcf\system\user\notification\UserNotificationHandler; @@ -89,13 +89,13 @@ private function deleteNotifications(): void private function deleteReactions(): void { - ReactionHandler::getInstance()->removeReactions( + (new DeleteObjectReactions( 'com.woltlab.wcf.comment.response', $this->responseIDs, UserNotificationHandler::getInstance()->getObjectTypeID($this->objectType->objectType . '.response.like.notification') ? [$this->objectType->objectType . '.response.like.notification'] : [] - ); + ))(); } private function deleteModerationQueues(): void diff --git a/wcfsetup/install/files/lib/command/reaction/DeleteObjectReactions.class.php b/wcfsetup/install/files/lib/command/reaction/DeleteObjectReactions.class.php new file mode 100644 index 00000000000..8f9fecb9924 --- /dev/null +++ b/wcfsetup/install/files/lib/command/reaction/DeleteObjectReactions.class.php @@ -0,0 +1,119 @@ + + * @since 6.3 + */ +final class DeleteObjectReactions +{ + /** + * @param int[] $objectIDs + * @param string[] $notificationObjectTypes + */ + public function __construct( + private readonly string $objectType, + private readonly array $objectIDs, + private readonly array $notificationObjectTypes = [] + ) {} + + public function __invoke(): void + { + $objectTypeObj = ReactionHandler::getInstance()->getObjectType($this->objectType); + if ($objectTypeObj === null) { + throw new \InvalidArgumentException('Given objectType is invalid.'); + } + + // get like objects + $likeObjectList = new LikeObjectList(); + $likeObjectList->getConditionBuilder()->add('like_object.objectTypeID = ?', [$objectTypeObj->objectTypeID]); + $likeObjectList->getConditionBuilder()->add('like_object.objectID IN (?)', [$this->objectIDs]); + $likeObjectList->readObjects(); + $likeObjects = $likeObjectList->getObjects(); + $likeObjectIDs = $likeObjectList->getObjectIDs(); + + // reduce count of received users + $users = []; + foreach ($likeObjects as $likeObject) { + if ($likeObject->likes && $likeObject->objectUserID) { + if (!isset($users[$likeObject->objectUserID])) { + $users[$likeObject->objectUserID] = 0; + } + + $users[$likeObject->objectUserID] -= $likeObject->likes; + } + } + + foreach ($users as $userID => $reactionData) { + $userEditor = new UserEditor(new User(null, ['userID' => $userID])); + $userEditor->updateCounters([ + 'likesReceived' => $reactionData, + ]); + } + + // get like ids + $likeList = new LikeList(); + $likeList->getConditionBuilder()->add('like_table.objectTypeID = ?', [$objectTypeObj->objectTypeID]); + $likeList->getConditionBuilder()->add('like_table.objectID IN (?)', [$this->objectIDs]); + $likeList->readObjects(); + + if (\count($likeList)) { + $activityPoints = $likeData = []; + foreach ($likeList as $like) { + $likeData[$like->likeID] = $like->userID; + + if ($like->objectUserID) { + if (!isset($activityPoints[$like->objectUserID])) { + $activityPoints[$like->objectUserID] = 0; + } + $activityPoints[$like->objectUserID]++; + } + } + + // delete like notifications + if ($this->notificationObjectTypes !== []) { + foreach ($this->notificationObjectTypes as $notificationObjectType) { + UserNotificationHandler::getInstance() + ->removeNotifications($notificationObjectType, $likeList->getObjectIDs()); + } + } elseif (UserNotificationHandler::getInstance()->getObjectTypeID($this->objectType . '.notification')) { + UserNotificationHandler::getInstance() + ->removeNotifications($this->objectType . '.notification', $likeList->getObjectIDs()); + } + + // revoke activity points + UserActivityPointHandler::getInstance() + ->removeEvents('com.woltlab.wcf.like.activityPointEvent.receivedLikes', $activityPoints); + + // delete likes + LikeEditor::deleteAll(\array_keys($likeData)); + } + + // delete like objects + if ($likeObjectIDs !== []) { + LikeObjectEditor::deleteAll($likeObjectIDs); + } + + // delete activity events + if (UserActivityEventHandler::getInstance()->getObjectTypeID($objectTypeObj->objectType . '.recentActivityEvent')) { + UserActivityEventHandler::getInstance() + ->removeEvents($objectTypeObj->objectType . '.recentActivityEvent', $this->objectIDs); + } + } +} diff --git a/wcfsetup/install/files/lib/command/reaction/RevertReaction.class.php b/wcfsetup/install/files/lib/command/reaction/RevertReaction.class.php new file mode 100644 index 00000000000..379b7375d46 --- /dev/null +++ b/wcfsetup/install/files/lib/command/reaction/RevertReaction.class.php @@ -0,0 +1,98 @@ + + * @since 6.3 + */ +final class RevertReaction +{ + public function __construct( + private readonly Like $like, + private readonly ILikeObject $likeable, + ) {} + + public function __invoke(): void + { + LikeObjectEditor::createFromLikeable($this->likeable); + + try { + WCF::getDB()->beginTransaction(); + + $likeObject = LikeObjectEditor::getLikeObjectForUpdate($this->likeable); + + (new LikeEditor($this->like))->delete(); + + $this->updateUserCounter($this->likeable); + + $this->likeable->updateLikeCounter($likeObject->likes - 1); + + LikeObjectEditor::rebuildLikeObjectData([$likeObject->getObjectID()]); + + WCF::getDB()->commitTransaction(); + } catch (DatabaseQueryException $e) { + WCF::getDB()->rollBackTransaction(); + + throw $e; + } + + $this->deleteUserActivityEvent( + $this->likeable, + UserRuntimeCache::getInstance()->getObject($this->like->userID) + ); + + EventHandler::getInstance()->fire( + new ReactionReverted($this->like, $this->likeable) + ); + } + + /** + * Updates the `likesReceived` counter of the likeable object's owner. + */ + private function updateUserCounter(ILikeObject $likeable): void + { + if (!$likeable->getUserID()) { + return; + } + + UserActivityPointHandler::getInstance()->removeEvents( + 'com.woltlab.wcf.like.activityPointEvent.receivedLikes', + [$likeable->getUserID() => 1] + ); + + $userEditor = new UserEditor(UserRuntimeCache::getInstance()->getObject($likeable->getUserID())); + $userEditor->updateCounters(['likesReceived' => -1]); + } + + private function deleteUserActivityEvent( + ILikeObject $likeable, + User $user, + ): void { + if (UserActivityEventHandler::getInstance()->getObjectTypeID($likeable->getObjectType()->objectType . '.recentActivityEvent')) { + UserActivityEventHandler::getInstance()->removeEvent( + $likeable->getObjectType()->objectType . '.recentActivityEvent', + $likeable->getObjectID(), + $user->userID + ); + } + } +} diff --git a/wcfsetup/install/files/lib/command/reaction/SetReaction.class.php b/wcfsetup/install/files/lib/command/reaction/SetReaction.class.php new file mode 100644 index 00000000000..bcb9402200e --- /dev/null +++ b/wcfsetup/install/files/lib/command/reaction/SetReaction.class.php @@ -0,0 +1,164 @@ + + * @since 6.3 + */ +final class SetReaction +{ + public function __construct( + private readonly ILikeObject $likeable, + private readonly User $user, + private readonly ReactionType $reactionType + ) {} + + public function __invoke(): void + { + LikeObjectEditor::createFromLikeable($this->likeable); + + try { + WCF::getDB()->beginTransaction(); + + $likeObject = LikeObjectEditor::getLikeObjectForUpdate($this->likeable); + + $originalLike = Like::getLike( + $this->likeable->getObjectType()->objectTypeID, + $this->likeable->getObjectID(), + $this->user->userID + ); + + if (!$originalLike->likeID) { + // new reaction + $like = LikeEditor::create([ + 'objectID' => $this->likeable->getObjectID(), + 'objectTypeID' => $this->likeable->getObjectType()->objectTypeID, + 'objectUserID' => $this->likeable->getUserID() ?: null, + 'userID' => $this->user->userID, + 'time' => \TIME_NOW, + 'likeValue' => 1, + 'reactionTypeID' => $this->reactionType->reactionTypeID, + ]); + + $this->updateUserCounter($this->likeable, $like); + + $this->likeable->updateLikeCounter($likeObject->likes + 1); + } else { + // update existing reaction + $editor = new LikeEditor($originalLike); + $editor->update([ + 'time' => \TIME_NOW, + 'likeValue' => 1, + 'reactionTypeID' => $this->reactionType->reactionTypeID, + ]); + + // reload like object to avoid stale object (reaction type id) + $like = new Like($originalLike->likeID); + } + + LikeObjectEditor::rebuildLikeObjectData([$likeObject->getObjectID()]); + + WCF::getDB()->commitTransaction(); + } catch (DatabaseQueryException $e) { + WCF::getDB()->rollBackTransaction(); + + throw $e; + } + + $this->updateUserActivityEvent( + $this->likeable, + $this->user, + $this->reactionType, + $originalLike, + ); + + // This interface should help to determine whether the plugin has been adapted to the API 5.2. + // If a LikeableObject does not implement this interface, no notification will be sent, because + // we assume, that the plugin has not been adapted to the new API. + if ($this->likeable instanceof IReactionObject) { + $this->likeable->sendNotification($like); + } + + EventHandler::getInstance()->fire( + new ReactionSet($this->likeable, $this->user, $this->reactionType) + ); + } + + /** + * Updates the `likesReceived` counter of the likeable object's owner. + */ + private function updateUserCounter(ILikeObject $likeable, Like $like): void + { + if (!$likeable->getUserID()) { + return; + } + + UserActivityPointHandler::getInstance()->fireEvent( + 'com.woltlab.wcf.like.activityPointEvent.receivedLikes', + $like->likeID, + $likeable->getUserID() + ); + + $userEditor = new UserEditor(UserRuntimeCache::getInstance()->getObject($likeable->getUserID())); + $userEditor->updateCounters(['likesReceived' => 1]); + } + + private function updateUserActivityEvent( + ILikeObject $likeable, + User $user, + ReactionType $reactionType, + Like $originalLike, + ): void { + if (UserActivityEventHandler::getInstance()->getObjectTypeID($likeable->getObjectType()->objectType . '.recentActivityEvent')) { + $objectType = ObjectTypeCache::getInstance()->getObjectTypeByName( + 'com.woltlab.wcf.user.recentActivityEvent', + $likeable->getObjectType()->objectType . '.recentActivityEvent' + ); + + if ($objectType->supportsReactions) { + if ($originalLike->likeID) { + UserActivityEventHandler::getInstance()->removeEvent( + $likeable->getObjectType()->objectType . '.recentActivityEvent', + $likeable->getObjectID(), + $user->userID + ); + } + + UserActivityEventHandler::getInstance()->fireEvent( + $likeable->getObjectType()->objectType . '.recentActivityEvent', + $likeable->getObjectID(), + $likeable->getLanguageID(), + $user->userID, + \TIME_NOW, + [ + 'reactionTypeID' => $reactionType->reactionTypeID, + /* @deprecated 6.1 use `reactionTypeID` */ + 'reactionType' => $reactionType, + ] + ); + } + } + } +} diff --git a/wcfsetup/install/files/lib/data/article/Article.class.php b/wcfsetup/install/files/lib/data/article/Article.class.php index 5afed4d0a7d..05c634d0e9d 100644 --- a/wcfsetup/install/files/lib/data/article/Article.class.php +++ b/wcfsetup/install/files/lib/data/article/Article.class.php @@ -34,7 +34,7 @@ * @property-read int $publicationDate timestamp at which the article will be automatically published or `0` if it has already been published * @property-read 0|1 $enableComments is `1` if comments are enabled for the article, otherwise `0` * @property-read int $views number of times the article has been viewed - * @property-read int $cumulativeLikes cumulative result of likes (counting `+1`) and dislikes (counting `-1`) for the article + * @property-read int $cumulativeLikes cumulative result of likes for the article * @property-read int $attachments number of attachments in the article descriptions * @property-read 0|1 $isDeleted is 1 if the article is in trash bin, otherwise 0 * @property-read 0|1 $hasLabels is `1` if labels are assigned to the article diff --git a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php index 49053f3347b..a1d9a8e59da 100644 --- a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php +++ b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php @@ -12,6 +12,7 @@ use wcf\command\article\SetArticleCategory; use wcf\command\article\SoftDeleteArticle; use wcf\command\article\UnpublishArticle; +use wcf\command\reaction\DeleteObjectReactions; use wcf\data\AbstractDatabaseObjectAction; use wcf\data\article\category\ArticleCategory; use wcf\data\article\content\ArticleContent; @@ -24,7 +25,6 @@ use wcf\system\exception\UserInputException; use wcf\system\language\LanguageFactory; use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; -use wcf\system\reaction\ReactionHandler; use wcf\system\request\LinkHandler; use wcf\system\search\SearchIndexManager; use wcf\system\tagging\TagEngine; @@ -432,7 +432,7 @@ public function delete() if (!empty($articleIDs)) { // delete like data - ReactionHandler::getInstance()->removeReactions('com.woltlab.wcf.likeableArticle', $articleIDs); + (new DeleteObjectReactions('com.woltlab.wcf.likeableArticle', $articleIDs))(); // delete comments CommentHandler::getInstance()->deleteObjects('com.woltlab.wcf.articleComment', $articleContentIDs); // delete tag to object entries diff --git a/wcfsetup/install/files/lib/data/like/LikeAction.class.php b/wcfsetup/install/files/lib/data/like/LikeAction.class.php index 9b7e98c6e99..6bce4ef71a2 100644 --- a/wcfsetup/install/files/lib/data/like/LikeAction.class.php +++ b/wcfsetup/install/files/lib/data/like/LikeAction.class.php @@ -3,19 +3,6 @@ namespace wcf\data\like; use wcf\data\AbstractDatabaseObjectAction; -use wcf\data\DatabaseObject; -use wcf\data\IGroupedUserListAction; -use wcf\data\like\object\ILikeObject; -use wcf\data\reaction\ReactionAction; -use wcf\system\cache\runtime\UserProfileRuntimeCache; -use wcf\system\exception\IllegalLinkException; -use wcf\system\exception\PermissionDeniedException; -use wcf\system\exception\UserInputException; -use wcf\system\like\LikeHandler; -use wcf\system\reaction\ReactionHandler; -use wcf\system\user\activity\event\UserActivityEventHandler; -use wcf\system\user\GroupedUserList; -use wcf\system\WCF; /** * Executes like-related actions. @@ -26,362 +13,11 @@ * @deprecated since 5.2, use \wcf\data\reaction\ReactionAction instead * * @extends AbstractDatabaseObjectAction - * @phpstan-type LikeData array{ - * likes: int, - * dislikes: int, - * cumulativeLikes: int, - * isLiked: 1|0, - * isDisliked: 1|0, - * containerID: int, - * newValue: 0, - * oldValue: 0, - * users: array{} - * } */ -class LikeAction extends AbstractDatabaseObjectAction implements IGroupedUserListAction +class LikeAction extends AbstractDatabaseObjectAction { - /** - * @inheritDoc - */ - protected $allowGuestAccess = ['getGroupedUserList', 'getLikeDetails', 'load']; - /** * @inheritDoc */ protected $className = LikeEditor::class; - - /** - * likeable object - * @var \wcf\data\like\object\ILikeObject - */ - public $likeableObject; - - /** - * object type object - * @var \wcf\data\object\type\ObjectType - */ - public $objectType; - - /** - * like object type provider object - * @var ILikeObjectTypeProvider - */ - public $objectTypeProvider; - - /** - * Validates parameters to fetch like details. - * - * @return void - */ - public function validateGetLikeDetails() - { - $this->validateObjectParameters(); - } - - /** - * Returns like details. - * - * @return array{containerID: int, template: string} - */ - public function getLikeDetails() - { - $sql = "SELECT userID, likeValue - FROM wcf1_like - WHERE objectID = ? - AND objectTypeID = ? - ORDER BY time DESC"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $this->parameters['data']['objectID'], - $this->objectType->objectTypeID, - ]); - $data = [ - Like::LIKE => [], - Like::DISLIKE => [], - ]; - while ($row = $statement->fetchArray()) { - $data[$row['likeValue']][] = $row['userID']; - } - - $values = []; - if (!empty($data[Like::LIKE])) { - $values[Like::LIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.like')); - $values[Like::LIKE]->addUserIDs($data[Like::LIKE]); - } - if (!empty($data[Like::DISLIKE])) { - $values[Like::DISLIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.dislike')); - $values[Like::DISLIKE]->addUserIDs($data[Like::DISLIKE]); - } - - // load user profiles - GroupedUserList::loadUsers(); - - return [ - 'containerID' => $this->parameters['data']['containerID'], - 'template' => WCF::getTPL()->render('wcf', 'groupedUserList', [ - 'groupedUsers' => $values, - ]), - ]; - } - - /** - * Validates parameters for like-related actions. - * - * @return void - */ - public function validateLike() - { - $this->validateObjectParameters(); - - // check permissions - if (!WCF::getUser()->userID || !WCF::getSession()->getPermission('user.like.canLike')) { - throw new PermissionDeniedException(); - } - - // check if liking own content but forbidden by configuration - $likeableObject = $this->objectTypeProvider->getObjectByID($this->parameters['data']['objectID']); - \assert($likeableObject instanceof ILikeObject); - $this->likeableObject = $likeableObject; - $this->likeableObject->setObjectType($this->objectType); - if ($this->likeableObject->getUserID() == WCF::getUser()->userID) { - throw new PermissionDeniedException(); - } - } - - /** - * @return LikeData - */ - public function like() - { - return $this->updateLike(Like::LIKE); - } - - /** - * @return void - */ - public function validateDislike() - { - // No longer supported since 5.2. - throw new PermissionDeniedException(); - } - - /** - * @return LikeData - */ - public function dislike() - { - return $this->updateLike(Like::DISLIKE); - } - - /** - * Sets like/dislike for an object, executing this method again with the same parameters - * will revert the status (removing like/dislike). - * - * @param int $likeValue - * @return LikeData - */ - protected function updateLike($likeValue) - { - $likeData = LikeHandler::getInstance()->like($this->likeableObject, WCF::getUser(), $likeValue); - - // handle activity event - if (UserActivityEventHandler::getInstance()->getObjectTypeID($this->objectType->objectType . '.recentActivityEvent')) { - if ($likeData['data']['liked'] == 1) { - UserActivityEventHandler::getInstance()->fireEvent( - $this->objectType->objectType . '.recentActivityEvent', - $this->parameters['data']['objectID'], - $this->likeableObject->getLanguageID() - ); - } else { - UserActivityEventHandler::getInstance()->removeEvent( - $this->objectType->objectType . '.recentActivityEvent', - $this->parameters['data']['objectID'] - ); - } - } - - // get stats - return [ - 'likes' => $likeData['data']['likes'], - 'dislikes' => $likeData['data']['dislikes'], - 'cumulativeLikes' => $likeData['data']['cumulativeLikes'], - 'isLiked' => ($likeData['data']['liked'] == 1) ? 1 : 0, - 'isDisliked' => ($likeData['data']['liked'] == -1) ? 1 : 0, - 'containerID' => $this->parameters['data']['containerID'], - 'newValue' => $likeData['newValue'], - 'oldValue' => $likeData['oldValue'], - 'users' => $likeData['users'], - ]; - } - - /** - * Validates permissions for given object. - * - * @return void - */ - protected function validateObjectParameters() - { - if (!MODULE_LIKE) { - throw new PermissionDeniedException(); - } - - $this->readString('containerID', false, 'data'); - $this->readInteger('objectID', false, 'data'); - $this->readString('objectType', false, 'data'); - - $this->objectType = ReactionHandler::getInstance()->getObjectType($this->parameters['data']['objectType']); - if ($this->objectType === null) { - throw new UserInputException('objectType'); - } - - $this->objectTypeProvider = $this->objectType->getProcessor(); - $this->likeableObject = $this->objectTypeProvider->getObjectByID($this->parameters['data']['objectID']); - $this->likeableObject->setObjectType($this->objectType); - if ($this->objectTypeProvider instanceof IRestrictedLikeObjectTypeProvider) { - if (!$this->objectTypeProvider->canViewLikes($this->likeableObject)) { - throw new PermissionDeniedException(); - } - } elseif (!$this->objectTypeProvider->checkPermissions($this->likeableObject)) { - throw new PermissionDeniedException(); - } - } - - /** - * @inheritDoc - */ - public function validateGetGroupedUserList() - { - $this->validateObjectParameters(); - - $this->readInteger('pageNo'); - - if ($this->parameters['pageNo'] < 1) { - throw new UserInputException('pageNo'); - } - } - - /** - * @inheritDoc - */ - public function getGroupedUserList() - { - // fetch number of pages - $sql = "SELECT COUNT(*) - FROM wcf1_like - WHERE objectID = ? - AND objectTypeID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $this->parameters['data']['objectID'], - $this->objectType->objectTypeID, - ]); - $pageCount = (int)\ceil($statement->fetchSingleColumn() / 20); - - $sql = "SELECT userID, likeValue - FROM wcf1_like - WHERE objectID = ? - AND objectTypeID = ? - ORDER BY likeValue DESC, time DESC"; - $statement = WCF::getDB()->prepare($sql, 20, ($this->parameters['pageNo'] - 1) * 20); - $statement->execute([ - $this->parameters['data']['objectID'], - $this->objectType->objectTypeID, - ]); - $data = [ - Like::LIKE => [], - Like::DISLIKE => [], - ]; - while ($row = $statement->fetchArray()) { - $data[$row['likeValue']][] = $row['userID']; - } - - $values = []; - if (!empty($data[Like::LIKE])) { - $values[Like::LIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.like')); - $values[Like::LIKE]->addUserIDs($data[Like::LIKE]); - } - if (!empty($data[Like::DISLIKE])) { - $values[Like::DISLIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.dislike')); - $values[Like::DISLIKE]->addUserIDs($data[Like::DISLIKE]); - } - - // load user profiles - GroupedUserList::loadUsers(); - - return [ - 'containerID' => $this->parameters['data']['containerID'], - 'pageCount' => $pageCount, - 'template' => WCF::getTPL()->render('wcf', 'groupedUserList', [ - 'groupedUsers' => $values, - ]), - ]; - } - - /** - * Validates parameters to load likes. - * - * @return void - */ - public function validateLoad() - { - if (!MODULE_LIKE) { - throw new IllegalLinkException(); - } - - $this->readInteger('lastLikeTime', true); - $this->readInteger('userID'); - $this->readInteger('likeValue'); - $this->readString('likeType'); - - $user = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']); - - if ($user === null) { - throw new IllegalLinkException(); - } - - if ($user->isProtected()) { - throw new PermissionDeniedException(); - } - } - - /** - * Loads a list of likes. - * - * @return array{lastLikeTime: int, template: string}|array{} - */ - public function load() - { - $likeList = new ViewableLikeList(); - if ($this->parameters['lastLikeTime']) { - $likeList->getConditionBuilder()->add("like_table.time < ?", [$this->parameters['lastLikeTime']]); - } - if ($this->parameters['likeType'] == 'received') { - $likeList->getConditionBuilder()->add("like_table.objectUserID = ?", [$this->parameters['userID']]); - } else { - $likeList->getConditionBuilder()->add("like_table.userID = ?", [$this->parameters['userID']]); - } - $likeList->getConditionBuilder()->add("like_table.likeValue = ?", [$this->parameters['likeValue']]); - $likeList->readObjects(); - if (!\count($likeList)) { - return []; - } - - return [ - 'lastLikeTime' => $likeList->getLastLikeTime(), - 'template' => WCF::getTPL()->render('wcf', 'userProfileLikeItem', [ - 'likeList' => $likeList, - ]), - ]; - } - - /** - * Copies likes from one object id to another. - * - * @return void - */ - public function copy() - { - $reactionAction = new ReactionAction([], 'copy', $this->getParameters()); - $reactionAction->executeAction(); - } } diff --git a/wcfsetup/install/files/lib/data/like/object/LikeObject.class.php b/wcfsetup/install/files/lib/data/like/object/LikeObject.class.php index 20f733f55d8..0d12575e905 100644 --- a/wcfsetup/install/files/lib/data/like/object/LikeObject.class.php +++ b/wcfsetup/install/files/lib/data/like/object/LikeObject.class.php @@ -5,25 +5,22 @@ use wcf\data\DatabaseObject; use wcf\data\object\type\ObjectTypeCache; use wcf\data\reaction\type\ReactionTypeCache; -use wcf\data\user\User; use wcf\system\WCF; /** * Represents a liked object. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License * * @property-read int $likeObjectID unique id of the liked object * @property-read int $objectTypeID id of the `com.woltlab.wcf.like.likeableObject` object type * @property-read int $objectID id of the liked object * @property-read ?int $objectUserID id of the user who created the liked object or null if user has been deleted or object was created by guest * @property-read int $likes number of likes of the liked object - * @property-read int $dislikes legacy column, not used anymore - * @property-read int $cumulativeLikes number of likes of the liked object - * @property-read ?string $cachedUsers serialized array with the ids and names of the three users who liked (+1) the object last - * @property-read ?string $cachedReactions serialized array with the reactionTypeIDs and the count of the reactions + * @property-read int $cumulativeLikes number of likes of the liked object (copy of $likes) + * @property-read ?string $cachedReactions JSON array with the reactionTypeIDs and the count of the reactions * @property-read int $reactionTypeID * @phpstan-type ReactionData array{ * reactionCount: int, @@ -39,17 +36,7 @@ class LikeObject extends DatabaseObject */ protected static $databaseTableIndexName = 'likeObjectID'; - /** - * liked object - * @var ILikeObject - */ - protected $likedObject; - - /** - * list of users who liked this object - * @var User[] - */ - protected $users = []; + protected ?ILikeObject $likedObject = null; /** * An array with all reaction types, which were received for the object. As key, the reactionTypeID @@ -57,36 +44,16 @@ class LikeObject extends DatabaseObject * an empty array is returned. * @var array */ - protected $reactions = []; + protected array $reactions = []; - /** - * The reputation for the current object. - * @var int - */ - protected $reputation; - - /** - * @inheritDoc - */ + #[\Override] protected function handleData($data) { parent::handleData($data); - // get user objects from cache - if (!empty($data['cachedUsers'])) { - $cachedUsers = @\unserialize($data['cachedUsers']); - - if (\is_array($cachedUsers)) { - foreach ($cachedUsers as $cachedUserData) { - $user = new User(null, $cachedUserData); - $this->users[$user->userID] = $user; - } - } - } - // get user objects from cache if (!empty($data['cachedReactions'])) { - $cachedReactions = @\unserialize($data['cachedReactions']); + $cachedReactions = \json_decode($data['cachedReactions'], true, flags: \JSON_THROW_ON_ERROR); if (\is_array($cachedReactions)) { foreach ($cachedReactions as $reactionTypeID => $reactionCount) { @@ -104,44 +71,12 @@ protected function handleData($data) } } } - } - - /** - * Since version 5.2, this method returns all reactionCounts for the different reactionTypes, - * instead of the user (as the method name suggests). This behavior is intentional and helps - * to establish backward compatibility. - * - * @return mixed[] - * @deprecated since 5.2 - */ - public function getUsers() - { - $returnValues = []; - - foreach ($this->getReactions() as $reactionID => $reaction) { - $returnValues[] = (object)[ - 'userID' => $reactionID, - 'username' => $reaction['reactionCount'], - ]; - } - - // this value is only set, if the object was loaded over the ReactionHandler::loadLikeObjects() - if ($this->reactionTypeID) { - $returnValues[] = (object)[ - 'userID' => 'reactionTypeID', - 'username' => $this->reactionTypeID, - ]; - } - return $returnValues; + // Old property that is set for backward compatibility reasons. + $this->data['dislikes'] = 0; } - /** - * Returns the liked object. - * - * @return ILikeObject - */ - public function getLikedObject() + public function getLikedObject(): ?ILikeObject { if ($this->likedObject === null) { $this->likedObject = ObjectTypeCache::getInstance() @@ -159,7 +94,7 @@ public function getLikedObject() * @return array * @since 5.2 */ - public function getReactions() + public function getReactions(): array { return $this->reactions; } @@ -181,23 +116,23 @@ public function getReactionsJson(): string } /** - * Sets the liked object. - * - * @return void + * @return array + * @since 6.3 */ - public function setLikedObject(ILikeObject $likedObject) + public function getCachedReactions(): array { - $this->likedObject = $likedObject; + $data = []; + foreach ($this->reactions as $reactionTypeID => $value) { + $data[$reactionTypeID] = $value['reactionCount']; + } + + return $data; } /** * Returns the like object with the given type and object id. - * - * @param int $objectTypeID - * @param int $objectID - * @return LikeObject */ - public static function getLikeObject($objectTypeID, $objectID) + public static function getLikeObject(int $objectTypeID, int $objectID): LikeObject { $sql = "SELECT * FROM wcf1_like_object diff --git a/wcfsetup/install/files/lib/data/like/object/LikeObjectAction.class.php b/wcfsetup/install/files/lib/data/like/object/LikeObjectAction.class.php index a1bbc0e2b72..e99543077e6 100644 --- a/wcfsetup/install/files/lib/data/like/object/LikeObjectAction.class.php +++ b/wcfsetup/install/files/lib/data/like/object/LikeObjectAction.class.php @@ -7,10 +7,9 @@ /** * Executes like object-related actions. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @since 5.2 + * @author Joshua Ruesweg + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License * * @extends AbstractDatabaseObjectAction */ diff --git a/wcfsetup/install/files/lib/data/like/object/LikeObjectEditor.class.php b/wcfsetup/install/files/lib/data/like/object/LikeObjectEditor.class.php index 19cd6278f28..1575ccba237 100644 --- a/wcfsetup/install/files/lib/data/like/object/LikeObjectEditor.class.php +++ b/wcfsetup/install/files/lib/data/like/object/LikeObjectEditor.class.php @@ -3,15 +3,17 @@ namespace wcf\data\like\object; use wcf\data\DatabaseObjectEditor; +use wcf\system\database\util\PreparedStatementConditionBuilder; +use wcf\system\WCF; /** * Extends the LikeObject object with functions to create, update and delete liked objects. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License * - * @mixin LikeObject + * @mixin LikeObject * @extends DatabaseObjectEditor */ class LikeObjectEditor extends DatabaseObjectEditor @@ -20,4 +22,62 @@ class LikeObjectEditor extends DatabaseObjectEditor * @inheritDoc */ protected static $baseClass = LikeObject::class; + + /** + * Recalculates the values for the columns `likes`, `cumulativeLikes` and `cachedReactions` for the given rows. + * + * @param list $likeObjectIDs + * @since 6.3 + */ + public static function rebuildLikeObjectData(array $likeObjectIDs): void + { + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('likeObjectID IN (?)', [$likeObjectIDs]); + + $sql = "UPDATE wcf1_like_object like_object + SET likes = ( + SELECT COUNT(*) + FROM wcf1_like + WHERE objectTypeID = like_object.objectTypeID + AND objectID = like_object.objectID + ), + cumulativeLikes = likes, + cachedReactions = ( + SELECT JSON_OBJECTAGG(reactionTypeID, count) + FROM (SELECT reactionTypeID, COUNT(*) AS count FROM wcf1_like WHERE objectTypeID = like_object.objectTypeID AND objectID = like_object.objectID GROUP BY reactionTypeID) AS cachedReactions + ) + " . $conditionBuilder; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + } + + /** + * Creates a row for the given likeable object. + * + * @since 6.3 + */ + public static function createFromLikeable(ILikeObject $likeable): void + { + $sql = "INSERT IGNORE INTO wcf1_like_object (objectTypeID, objectID, objectUserID) VALUES (?, ?, ?)"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $likeable->getObjectType()->objectTypeID, + $likeable->getObjectID(), + $likeable->getUserID(), + ]); + } + + /** + * Returns the `LikeObject` for the given likeable object and locks the row for an update. + * + * @since 6.3 + */ + public static function getLikeObjectForUpdate(ILikeObject $likeable): LikeObject + { + $sql = "SELECT * FROM wcf1_like_object WHERE objectTypeID = ? AND objectID = ? FOR UPDATE"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$likeable->getObjectType()->objectTypeID, $likeable->getObjectID()]); + + return $statement->fetchSingleObject(LikeObject::class); + } } diff --git a/wcfsetup/install/files/lib/data/like/object/LikeObjectList.class.php b/wcfsetup/install/files/lib/data/like/object/LikeObjectList.class.php index 98860820309..9b9306a266b 100644 --- a/wcfsetup/install/files/lib/data/like/object/LikeObjectList.class.php +++ b/wcfsetup/install/files/lib/data/like/object/LikeObjectList.class.php @@ -7,9 +7,9 @@ /** * Represents a list of like objects. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License * * @extends DatabaseObjectList */ diff --git a/wcfsetup/install/files/lib/event/reaction/ReactionReverted.class.php b/wcfsetup/install/files/lib/event/reaction/ReactionReverted.class.php new file mode 100644 index 00000000000..dfc766a1e6c --- /dev/null +++ b/wcfsetup/install/files/lib/event/reaction/ReactionReverted.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class ReactionReverted implements IPsr14Event +{ + public function __construct( + public readonly Like $like, + public readonly ILikeObject $likeable, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/reaction/ReactionSet.class.php b/wcfsetup/install/files/lib/event/reaction/ReactionSet.class.php new file mode 100644 index 00000000000..7d332f32918 --- /dev/null +++ b/wcfsetup/install/files/lib/event/reaction/ReactionSet.class.php @@ -0,0 +1,25 @@ + + * @since 6.3 + */ +final class ReactionSet implements IPsr14Event +{ + public function __construct( + public readonly ILikeObject $likeable, + public readonly User $user, + public readonly ReactionType $reactionType + ) {} +} diff --git a/wcfsetup/install/files/lib/system/comment/CommentHandler.class.php b/wcfsetup/install/files/lib/system/comment/CommentHandler.class.php index 45e529cda0d..ac4a63049ae 100644 --- a/wcfsetup/install/files/lib/system/comment/CommentHandler.class.php +++ b/wcfsetup/install/files/lib/system/comment/CommentHandler.class.php @@ -2,6 +2,7 @@ namespace wcf\system\comment; +use wcf\command\reaction\DeleteObjectReactions; use wcf\data\comment\CommentEditor; use wcf\data\comment\CommentList; use wcf\data\comment\response\CommentResponse; @@ -18,7 +19,6 @@ use wcf\system\exception\UserInputException; use wcf\system\flood\FloodControl; use wcf\system\message\censorship\Censorship; -use wcf\system\reaction\ReactionHandler; use wcf\system\SingletonFactory; use wcf\system\user\activity\event\UserActivityEventHandler; use wcf\system\user\notification\UserNotificationHandler; @@ -174,11 +174,11 @@ public function deleteObjects($objectType, array $objectIDs) $notificationObjectTypes[] = $objectTypeObj->objectType . '.like.notification'; } - ReactionHandler::getInstance()->removeReactions( + (new DeleteObjectReactions( 'com.woltlab.wcf.comment', $commentIDs, $notificationObjectTypes - ); + ))(); // delete activity events if (UserActivityEventHandler::getInstance()->getObjectTypeID($objectTypeObj->objectType . '.recentActivityEvent')) { @@ -198,11 +198,11 @@ public function deleteObjects($objectType, array $objectIDs) $notificationObjectTypes[] = $objectTypeObj->objectType . '.response.like.notification'; } - ReactionHandler::getInstance()->removeReactions( + (new DeleteObjectReactions( 'com.woltlab.wcf.comment.response', $responseIDs, $notificationObjectTypes - ); + ))(); // delete activity events (for responses) if (UserActivityEventHandler::getInstance()->getObjectTypeID($objectTypeObj->objectType . '.response.recentActivityEvent')) { diff --git a/wcfsetup/install/files/lib/system/like/LikeHandler.class.php b/wcfsetup/install/files/lib/system/like/LikeHandler.class.php index 3653787e265..9d789ba44ba 100644 --- a/wcfsetup/install/files/lib/system/like/LikeHandler.class.php +++ b/wcfsetup/install/files/lib/system/like/LikeHandler.class.php @@ -190,7 +190,7 @@ public function removeLikes($objectType, array $objectIDs, array $notificationOb */ protected function loadLikeStatus(LikeObject $likeObject, User $user) { - $sql = "SELECT like_object.likes, like_object.dislikes, like_object.cumulativeLikes, + $sql = "SELECT like_object.likes, 0 AS dislikes, like_object.cumulativeLikes, CASE WHEN like_table.likeValue IS NOT NULL THEN like_table.likeValue ELSE 0 END AS liked FROM wcf1_like_object like_object LEFT JOIN wcf1_like like_table diff --git a/wcfsetup/install/files/lib/system/reaction/ReactionHandler.class.php b/wcfsetup/install/files/lib/system/reaction/ReactionHandler.class.php index f9c6afe7233..c741dd02571 100644 --- a/wcfsetup/install/files/lib/system/reaction/ReactionHandler.class.php +++ b/wcfsetup/install/files/lib/system/reaction/ReactionHandler.class.php @@ -2,82 +2,63 @@ namespace wcf\system\reaction; +use wcf\command\reaction\DeleteObjectReactions; +use wcf\command\reaction\SetReaction; +use wcf\command\reaction\RevertReaction; use wcf\data\DatabaseObject; use wcf\data\like\ILikeObjectTypeProvider; use wcf\data\like\Like; -use wcf\data\like\LikeList; use wcf\data\like\object\ILikeObject; use wcf\data\like\object\LikeObject; -use wcf\data\like\object\LikeObjectAction; -use wcf\data\like\object\LikeObjectList; use wcf\data\object\type\ObjectType; use wcf\data\object\type\ObjectTypeCache; -use wcf\data\reaction\object\IReactionObject; -use wcf\data\reaction\ReactionAction; use wcf\data\reaction\type\ReactionType; use wcf\data\reaction\type\ReactionTypeCache; use wcf\data\user\User; -use wcf\data\user\UserEditor; -use wcf\system\cache\runtime\UserRuntimeCache; -use wcf\system\database\exception\DatabaseQueryException; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\event\EventHandler; use wcf\system\exception\ImplementationException; use wcf\system\SingletonFactory; -use wcf\system\user\activity\event\UserActivityEventHandler; -use wcf\system\user\activity\point\UserActivityPointHandler; -use wcf\system\user\notification\UserNotificationHandler; use wcf\system\WCF; use wcf\util\StringUtil; /** * Handles the reactions of objects. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @since 5.2 + * @author Joshua Ruesweg, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License */ -class ReactionHandler extends SingletonFactory +final class ReactionHandler extends SingletonFactory { /** - * loaded like objects * @var LikeObject[][] */ - protected $likeObjectCache = []; + private array $likeObjectCache = []; /** - * cached object types * @var ObjectType[] */ - protected $cache; + private array $cache; /** - * Cache for likeable objects sorted by objectType. * @var ILikeObject[][] */ - private $likeableObjectsCache = []; + private array $likeableObjectsCache = []; - /** - * Creates a new ReactionHandler instance. - */ - protected function init() + protected function init(): void { $this->cache = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.like.likeableObject'); } /** * Returns the JSON encoded JavaScript variable for the template. - * - * @return string */ - public function getReactionsJSVariable() + public function getReactionsJSVariable(): string { - $reactions = ReactionTypeCache::getInstance()->getReactionTypes(); - $returnValues = []; - foreach ($reactions as $reaction) { + foreach ($this->getReactionTypes() as $reaction) { $returnValues[$reaction->reactionTypeID] = [ 'title' => $reaction->getTitle(), 'renderedIcon' => $reaction->renderIcon(), @@ -94,32 +75,22 @@ public function getReactionsJSVariable() /** * Returns all enabled reaction types. * - * @return ReactionType[] + * @return ReactionType[] */ - public function getReactionTypes() + public function getReactionTypes(): array { return ReactionTypeCache::getInstance()->getReactionTypes(); } - /** - * Returns a reaction type by id. - * - * @param int $reactionID - * @return ReactionType|null - */ - public function getReactionTypeByID($reactionID) + public function getReactionTypeByID(int $reactionID): ?ReactionType { return ReactionTypeCache::getInstance()->getReactionTypeByID($reactionID); } /** * Builds the data attributes for the object container. - * - * @param string $objectTypeName - * @param int $objectID - * @return string */ - public function getDataAttributes($objectTypeName, $objectID) + public function getDataAttributes(string $objectTypeName, int $objectID): string { $object = $this->getLikeableObject($objectTypeName, $objectID); @@ -149,13 +120,9 @@ public function getDataAttributes($objectTypeName, $objectID) } /** - * Cache likeable objects. - * - * @param string $objectTypeName - * @param int[] $objectIDs - * @return void + * @param list $objectIDs */ - public function cacheLikeableObjects($objectTypeName, array $objectIDs) + public function cacheLikeableObjects(string $objectTypeName, array $objectIDs): void { $objectType = $this->getObjectType($objectTypeName); if ($objectType === null) { @@ -180,12 +147,8 @@ public function cacheLikeableObjects($objectTypeName, array $objectIDs) /** * Get an likeable object from the internal cache. - * - * @param string $objectTypeName - * @param int $objectID - * @return ILikeObject */ - public function getLikeableObject($objectTypeName, $objectID) + public function getLikeableObject(string $objectTypeName, int $objectID): ILikeObject { if (!isset($this->likeableObjectsCache[$objectTypeName][$objectID])) { $this->cacheLikeableObjects($objectTypeName, [$objectID]); @@ -210,25 +173,12 @@ public function getLikeableObject($objectTypeName, $objectID) return $likeableObject; } - /** - * Returns an object type from cache. - * - * @param string $objectName - * @return ObjectType|null - */ - public function getObjectType($objectName) + public function getObjectType(string $objectName): ?ObjectType { return $this->cache[$objectName] ?? null; } - /** - * Returns a like object. - * - * @param ObjectType $objectType - * @param int $objectID - * @return LikeObject|null - */ - public function getLikeObject(ObjectType $objectType, $objectID) + public function getLikeObject(ObjectType $objectType, int $objectID): ?LikeObject { if (!isset($this->likeObjectCache[$objectType->objectTypeID][$objectID])) { $this->loadLikeObjects($objectType, [$objectID]); @@ -240,10 +190,9 @@ public function getLikeObject(ObjectType $objectType, $objectID) /** * Returns the like objects of a specific object type. * - * @param ObjectType $objectType - * @return LikeObject[] + * @return LikeObject[] */ - public function getLikeObjects(ObjectType $objectType) + public function getLikeObjects(ObjectType $objectType): array { if (isset($this->likeObjectCache[$objectType->objectTypeID])) { return $this->likeObjectCache[$objectType->objectTypeID]; @@ -254,13 +203,11 @@ public function getLikeObjects(ObjectType $objectType) /** * Loads the like data for a set of objects and returns the number of loaded - * like objects + * like objects. * - * @param ObjectType $objectType - * @param int[] $objectIDs - * @return int + * @param list $objectIDs */ - public function loadLikeObjects(ObjectType $objectType, array $objectIDs) + public function loadLikeObjects(ObjectType $objectType, array $objectIDs): int { if (empty($objectIDs)) { return 0; @@ -306,10 +253,6 @@ public function loadLikeObjects(ObjectType $objectType, array $objectIDs) /** * Add a reaction to an object. * - * @param ILikeObject $likeable - * @param User $user - * @param int $reactionTypeID - * @param int $time * @return array{ * cachedReactions: array, * reactionTypeID: ?int, @@ -317,8 +260,9 @@ public function loadLikeObjects(ObjectType $objectType, array $objectIDs) * likeObject: LikeObject|array{}, * cumulativeLikes: int, * } + * @deprecated 6.3 Use `SetReaction` command instead. */ - public function react(ILikeObject $likeable, User $user, $reactionTypeID, $time = TIME_NOW) + public function react(ILikeObject $likeable, User $user, int $reactionTypeID, int $time = \TIME_NOW): array { // verify if object is already liked by user $like = Like::getLike($likeable->getObjectType()->objectTypeID, $likeable->getObjectID(), $user->userID); @@ -333,504 +277,69 @@ public function react(ILikeObject $likeable, User $user, $reactionTypeID, $time $reaction = ReactionTypeCache::getInstance()->getReactionTypeByID($reactionTypeID); - try { - WCF::getDB()->beginTransaction(); - - $likeObjectData = $this->updateLikeObject($likeable, $likeObject, $like, $reaction); - - // update owner's like counter - $this->updateUsersLikeCounter($likeable, $likeObject, $like, $reaction); - - if (!$like->likeID) { - // save like - $sql = "INSERT INTO wcf1_like - (objectID, objectTypeID, objectUserID, userID, time, likeValue, reactionTypeID) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE time = ?, - likeValue = ?, - reactionTypeID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $likeable->getObjectID(), - $likeable->getObjectType()->objectTypeID, - $likeable->getUserID() ?: null, - $user->userID, - $time, - 1, - $reactionTypeID, - $time, - 1, - $reactionTypeID, - ]); - $like = new Like(WCF::getDB()->getInsertID("wcf1_like", "likeID")); - - if ($likeable->getUserID()) { - UserActivityPointHandler::getInstance()->fireEvent( - 'com.woltlab.wcf.like.activityPointEvent.receivedLikes', - $like->likeID, - $likeable->getUserID() - ); - } - } else { - (new ReactionAction([$like], 'update', [ - 'data' => [ - 'time' => $time, - 'likeValue' => 1, - 'reactionTypeID' => $reactionTypeID, - ], - ]))->executeAction(); - - if ($like->reactionTypeID == $reactionTypeID) { - if ($likeable->getUserID()) { - UserActivityPointHandler::getInstance()->removeEvents( - 'com.woltlab.wcf.like.activityPointEvent.receivedLikes', - [$likeable->getUserID() => 1] - ); - } - } - } - - // update object's like counter - $likeable->updateLikeCounter($likeObjectData['cumulativeLikes']); - - // update recent activity - if (UserActivityEventHandler::getInstance()->getObjectTypeID($likeable->getObjectType()->objectType . '.recentActivityEvent')) { - $objectType = ObjectTypeCache::getInstance()->getObjectTypeByName( - 'com.woltlab.wcf.user.recentActivityEvent', - $likeable->getObjectType()->objectType . '.recentActivityEvent' - ); - - if ($objectType->supportsReactions) { - if ($like->likeID) { - UserActivityEventHandler::getInstance()->removeEvent( - $likeable->getObjectType()->objectType . '.recentActivityEvent', - $likeable->getObjectID(), - $user->userID - ); - } - - UserActivityEventHandler::getInstance()->fireEvent( - $likeable->getObjectType()->objectType . '.recentActivityEvent', - $likeable->getObjectID(), - $likeable->getLanguageID(), - $user->userID, - TIME_NOW, - [ - 'reactionTypeID' => $reaction->reactionTypeID, - /* @deprecated 6.1 use `reactionTypeID` */ - 'reactionType' => $reaction, - ] - ); - } - } - - WCF::getDB()->commitTransaction(); - - // This interface should help to determine whether the plugin has been adapted to the API 5.2. - // If a LikeableObject does not implement this interface, no notification will be sent, because - // we assume, that the plugin has not been adapted to the new API. - if ($likeable instanceof IReactionObject) { - $likeable->sendNotification($like); - } + (new SetReaction($likeable, $user, $reaction))(); - return [ - 'cachedReactions' => $likeObjectData['cachedReactions'], - 'reactionTypeID' => $reactionTypeID, - 'like' => $like, - 'likeObject' => $likeObjectData['likeObject'], - 'cumulativeLikes' => $likeObjectData['cumulativeLikes'], - ]; - } catch (DatabaseQueryException $e) { - WCF::getDB()->rollBackTransaction(); - - throw $e; - } - - // @phpstan-ignore deadCode.unreachable - throw new \LogicException('Unreachable'); - } - - /** - * Creates or updates a LikeObject for an likable object. - * - * @param ILikeObject $likeable - * @param LikeObject $likeObject - * @param Like $like - * @param ReactionType $reactionType - * @return array{cumulativeLikes: int, cachedReactions: array, likeObject: LikeObject} - */ - private function updateLikeObject( - ILikeObject $likeable, - LikeObject $likeObject, - Like $like, - ReactionType $reactionType - ) { - // update existing object - if ($likeObject->likeObjectID) { - $cumulativeLikes = $likeObject->cumulativeLikes; - - if ($likeObject->cachedReactions !== null) { - $cachedReactions = @\unserialize($likeObject->cachedReactions); - } else { - $cachedReactions = []; - } - - if (!\is_array($cachedReactions)) { - $cachedReactions = []; - } - - if ($like->likeID) { - $cumulativeLikes--; - - if (isset($cachedReactions[$like->getReactionType()->reactionTypeID])) { - if (--$cachedReactions[$like->getReactionType()->reactionTypeID] == 0) { - unset($cachedReactions[$like->getReactionType()->reactionTypeID]); - } - } - } - - $cumulativeLikes++; - - if (isset($cachedReactions[$reactionType->reactionTypeID])) { - $cachedReactions[$reactionType->reactionTypeID]++; - } else { - $cachedReactions[$reactionType->reactionTypeID] = 1; - } - - $cachedReactions = self::cleanUpCachedReactions($cachedReactions); - - // build update date - $updateData = [ - 'likes' => $cumulativeLikes, - 'dislikes' => 0, - 'cumulativeLikes' => $cumulativeLikes, - 'cachedReactions' => \serialize($cachedReactions), - ]; - - // update data - (new LikeObjectAction([$likeObject], 'update', ['data' => $updateData]))->executeAction(); - } else { - $cumulativeLikes = 1; - $cachedReactions = [ - $reactionType->reactionTypeID => 1, - ]; - - // create cache - $sql = "INSERT INTO wcf1_like_object - (objectTypeID, objectID, objectUserID, likes, dislikes, cumulativeLikes, cachedReactions) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE likes = ?, - dislikes = ?, - cumulativeLikes = ?, - cachedReactions = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $likeable->getObjectType()->objectTypeID, - $likeable->getObjectID(), - $likeable->getUserID() ?: null, - $cumulativeLikes, - 0, - $cumulativeLikes, - \serialize($cachedReactions), - $cumulativeLikes, - 0, - $cumulativeLikes, - \serialize($cachedReactions), - ]); - $likeObject = new LikeObject(WCF::getDB()->getInsertID("wcf1_like_object", "likeObjectID")); - } + $like = Like::getLike($likeable->getObjectType()->objectTypeID, $likeable->getObjectID(), $user->userID); + $likeObject = LikeObject::getLikeObject($likeable->getObjectType()->objectTypeID, $likeable->getObjectID()); return [ - 'cumulativeLikes' => $cumulativeLikes, - 'cachedReactions' => $cachedReactions, + 'cachedReactions' => $likeObject->getCachedReactions(), + 'reactionTypeID' => $reactionTypeID, + 'like' => $like, 'likeObject' => $likeObject, + 'cumulativeLikes' => $likeObject->cumulativeLikes, ]; } - /** - * Updates the like counter for a user. - * - * @param ILikeObject $likeable - * @param LikeObject $likeObject - * @param Like $like - * @param ReactionType $reactionType - * @return void - */ - private function updateUsersLikeCounter( - ILikeObject $likeable, - LikeObject $likeObject, - Like $like, - ?ReactionType $reactionType = null - ) { - if ($likeable->getUserID()) { - $likesReceived = 0; - if ($like->likeID) { - $likesReceived--; - } - - if ($reactionType !== null) { - $likesReceived++; - } - - if ($likesReceived !== 0) { - $userEditor = new UserEditor(UserRuntimeCache::getInstance()->getObject($likeable->getUserID())); - $userEditor->updateCounters(['likesReceived' => $likesReceived]); - } - } - } - /** * Reverts a reaction for an object. * - * @param Like $like - * @param ILikeObject $likeable - * @param LikeObject $likeObject - * @param User $user * @return array{ * cachedReactions: array, * reactionTypeID: null, * likeObject: LikeObject|array{}, * cumulativeLikes: ?int, * } + * @deprecated 6.3 Use `RevertReaction` command instead. */ - public function revertReact(Like $like, ILikeObject $likeable, LikeObject $likeObject, User $user) + public function revertReact(Like $like, ILikeObject $likeable, LikeObject $likeObject, User $user): array { if (!$like->likeID) { throw new \InvalidArgumentException('The given parameter $like is invalid.'); } - try { - WCF::getDB()->beginTransaction(); - - $likeObjectData = $this->revertLikeObject($likeObject, $like); + (new RevertReaction($like, $likeable))(); - // update owner's like counter - $this->updateUsersLikeCounter($likeable, $likeObject, $like, null); - - (new ReactionAction([$like], 'delete'))->executeAction(); - - if ($likeable->getUserID()) { - UserActivityPointHandler::getInstance()->removeEvents( - 'com.woltlab.wcf.like.activityPointEvent.receivedLikes', - [$likeable->getUserID() => 1] - ); - } - - // update object's like counter - $likeable->updateLikeCounter($likeObjectData['cumulativeLikes']); - - // delete recent activity - if (UserActivityEventHandler::getInstance()->getObjectTypeID($likeable->getObjectType()->objectType . '.recentActivityEvent')) { - UserActivityEventHandler::getInstance()->removeEvent( - $likeable->getObjectType()->objectType . '.recentActivityEvent', - $likeable->getObjectID(), - $user->userID - ); - } - - WCF::getDB()->commitTransaction(); - - return [ - 'cachedReactions' => $likeObjectData['cachedReactions'], - 'reactionTypeID' => null, - 'likeObject' => $likeObjectData['likeObject'], - 'cumulativeLikes' => $likeObjectData['cumulativeLikes'], - ]; - } catch (DatabaseQueryException $e) { - WCF::getDB()->rollBackTransaction(); - } + $likeObject = LikeObject::getLikeObject($likeable->getObjectType()->objectTypeID, $likeable->getObjectID()); return [ - 'cachedReactions' => [], + 'cachedReactions' => $likeObject->getCachedReactions(), 'reactionTypeID' => null, - 'likeObject' => [], - 'cumulativeLikes' => null, - ]; - } - - /** - * Creates or updates a LikeObject for an likable object. - * - * @param LikeObject $likeObject - * @param Like $like - * @return array{cumulativeLikes: int, cachedReactions: array, likeObject: LikeObject} - */ - private function revertLikeObject(LikeObject $likeObject, Like $like) - { - if (!$likeObject->likeObjectID) { - throw new \InvalidArgumentException('The given parameter $likeObject is invalid.'); - } - - // update existing object - $cumulativeLikes = $likeObject->cumulativeLikes; - $cachedReactions = @\unserialize($likeObject->cachedReactions); - if (!\is_array($cachedReactions)) { - $cachedReactions = []; - } - - if ($like->likeID) { - $cumulativeLikes--; - - if (isset($cachedReactions[$like->getReactionType()->reactionTypeID])) { - if (--$cachedReactions[$like->getReactionType()->reactionTypeID] == 0) { - unset($cachedReactions[$like->getReactionType()->reactionTypeID]); - } - } - - $cachedReactions = self::cleanUpCachedReactions($cachedReactions); - - // build update date - $updateData = [ - 'likes' => $cumulativeLikes, - 'dislikes' => 0, - 'cumulativeLikes' => $cumulativeLikes, - 'cachedReactions' => \serialize($cachedReactions), - ]; - - // update data - (new LikeObjectAction([$likeObject], 'update', ['data' => $updateData]))->executeAction(); - } - - return [ - 'cumulativeLikes' => $cumulativeLikes, - 'cachedReactions' => $cachedReactions, 'likeObject' => $likeObject, + 'cumulativeLikes' => $likeObject->cumulativeLikes, ]; } /** * Removes all reactions for given objects. * - * @param string $objectType * @param int[] $objectIDs * @param string[] $notificationObjectTypes - * @return void - */ - public function removeReactions($objectType, array $objectIDs, array $notificationObjectTypes = []) - { - $objectTypeObj = $this->getObjectType($objectType); - - if ($objectTypeObj === null) { - throw new \InvalidArgumentException('Given objectType is invalid.'); - } - - // get like objects - $likeObjectList = new LikeObjectList(); - $likeObjectList->getConditionBuilder()->add('like_object.objectTypeID = ?', [$objectTypeObj->objectTypeID]); - $likeObjectList->getConditionBuilder()->add('like_object.objectID IN (?)', [$objectIDs]); - $likeObjectList->readObjects(); - $likeObjects = $likeObjectList->getObjects(); - $likeObjectIDs = $likeObjectList->getObjectIDs(); - - // reduce count of received users - $users = []; - foreach ($likeObjects as $likeObject) { - if ($likeObject->likes && $likeObject->objectUserID) { - if (!isset($users[$likeObject->objectUserID])) { - $users[$likeObject->objectUserID] = 0; - } - - $users[$likeObject->objectUserID] -= \count($likeObject->getReactions()); - } - } - - foreach ($users as $userID => $reactionData) { - $userEditor = new UserEditor(new User(null, ['userID' => $userID])); - $userEditor->updateCounters([ - 'likesReceived' => $reactionData, - ]); - } - - // get like ids - $likeList = new LikeList(); - $likeList->getConditionBuilder()->add('like_table.objectTypeID = ?', [$objectTypeObj->objectTypeID]); - $likeList->getConditionBuilder()->add('like_table.objectID IN (?)', [$objectIDs]); - $likeList->readObjects(); - - if (\count($likeList)) { - $activityPoints = $likeData = []; - foreach ($likeList as $like) { - $likeData[$like->likeID] = $like->userID; - - if ($like->objectUserID) { - if (!isset($activityPoints[$like->objectUserID])) { - $activityPoints[$like->objectUserID] = 0; - } - $activityPoints[$like->objectUserID]++; - } - } - - // delete like notifications - if (!empty($notificationObjectTypes)) { - foreach ($notificationObjectTypes as $notificationObjectType) { - UserNotificationHandler::getInstance() - ->removeNotifications($notificationObjectType, $likeList->getObjectIDs()); - } - } elseif (UserNotificationHandler::getInstance()->getObjectTypeID($objectType . '.notification')) { - UserNotificationHandler::getInstance() - ->removeNotifications($objectType . '.notification', $likeList->getObjectIDs()); - } - - // revoke activity points - UserActivityPointHandler::getInstance() - ->removeEvents('com.woltlab.wcf.like.activityPointEvent.receivedLikes', $activityPoints); - - // delete likes - (new ReactionAction(\array_keys($likeData), 'delete'))->executeAction(); - } - - // delete like objects - if (!empty($likeObjectIDs)) { - (new LikeObjectAction($likeObjectIDs, 'delete'))->executeAction(); - } - - // delete activity events - if (UserActivityEventHandler::getInstance()->getObjectTypeID($objectTypeObj->objectType . '.recentActivityEvent')) { - UserActivityEventHandler::getInstance() - ->removeEvents($objectTypeObj->objectType . '.recentActivityEvent', $objectIDs); - } - } - - /** - * Returns current like object status. - * - * @param LikeObject $likeObject - * @param User $user - * @return array{ - * likes: int, - * dislikes: int, - * cumulativeLikes: int, - * reactionTypeID: int, - * likeValue: int - * } + * @deprecated 6.3 Use `DeleteObjectReactions` command instead. */ - protected function loadLikeStatus(LikeObject $likeObject, User $user) + public function removeReactions(string $objectType, array $objectIDs, array $notificationObjectTypes = []): void { - $sql = "SELECT like_object.likes, like_object.dislikes, like_object.cumulativeLikes, - COALESCE(like_table.reactionTypeID, 0) AS reactionTypeID, - COALESCE(like_table.likeValue, 0) AS liked - FROM wcf1_like_object like_object - LEFT JOIN wcf1_like like_table - ON like_table.objectTypeID = ? - AND like_table.objectID = like_object.objectID - AND like_table.userID = ? - WHERE like_object.likeObjectID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $likeObject->objectTypeID, - $user->userID, - $likeObject->likeObjectID, - ]); - - return $statement->fetchArray(); + (new DeleteObjectReactions( + $objectType, + $objectIDs, + $notificationObjectTypes + ))(); } /** * Returns the first available reaction type. - * - * @return ReactionType|null */ - public function getFirstReactionType() + public function getFirstReactionType(): ReactionType|false { static $firstReactionType; @@ -846,10 +355,8 @@ public function getFirstReactionType() /** * Returns the first available reaction type's id. - * - * @return int|null */ - public function getFirstReactionTypeID() + public function getFirstReactionTypeID(): ?int { $firstReactionType = $this->getFirstReactionType(); @@ -862,7 +369,7 @@ public function getFirstReactionTypeID() * @param array $cachedReactions * @return array */ - private function cleanUpCachedReactions(array $cachedReactions) + private function cleanUpCachedReactions(array $cachedReactions): array { foreach ($cachedReactions as $reactionTypeID => $count) { if (self::getReactionTypeByID($reactionTypeID) === null) { @@ -874,14 +381,12 @@ private function cleanUpCachedReactions(array $cachedReactions) } /** - * @param string|null $cachedReactions - * @return array{count: int, other: int, reaction: ?ReactionType}|null - * @since 5.2 + * @return ?array{count: int, other: int, reaction: ?ReactionType} */ - public function getTopReaction($cachedReactions) + public function getTopReaction(?string $cachedReactionsJson): ?array { - if ($cachedReactions) { - $cachedReactions = @\unserialize($cachedReactions); + if ($cachedReactionsJson) { + $cachedReactions = \json_decode($cachedReactionsJson, true, flags: \JSON_THROW_ON_ERROR); if (\is_array($cachedReactions)) { $cachedReactions = self::cleanUpCachedReactions($cachedReactions); @@ -909,10 +414,9 @@ public function getTopReaction($cachedReactions) * Renders an inline list of reaction counts. * * @param int[] $reactionCounts format: `[reactionID => count]` - * @return string - * @since 5.3 + * @since 5.3 */ - public function renderInlineList(array $reactionCounts) + public function renderInlineList(array $reactionCounts): string { $reactionsOuput = []; foreach ($reactionCounts as $reactionTypeID => $count) { diff --git a/wcfsetup/install/files/lib/system/worker/LikeRebuildDataWorker.class.php b/wcfsetup/install/files/lib/system/worker/LikeRebuildDataWorker.class.php index cf26bd8def8..e6c3ff9593f 100644 --- a/wcfsetup/install/files/lib/system/worker/LikeRebuildDataWorker.class.php +++ b/wcfsetup/install/files/lib/system/worker/LikeRebuildDataWorker.class.php @@ -48,7 +48,6 @@ public function execute() // reset like object data $sql = "UPDATE wcf1_like_object SET likes = 0, - dislikes = 0, cumulativeLikes = 0, cachedReactions = NULL"; $statement = WCF::getDB()->prepare($sql); @@ -113,7 +112,7 @@ public function execute() $condition = new PreparedStatementConditionBuilder(); $condition->add('objectTypeID = ?', [$objectTypeID]); $condition->add('objectID IN (?)', [\array_keys($objects)]); - $sql = "SELECT objectTypeID, objectID, objectUserID, likes, dislikes, cumulativeLikes, cachedReactions + $sql = "SELECT objectTypeID, objectID, objectUserID, likes, cumulativeLikes, cachedReactions FROM wcf1_like_object " . $condition; $objectStatement = WCF::getDB()->prepare($sql); @@ -124,14 +123,13 @@ public function execute() } $sql = "INSERT INTO wcf1_like_object - (objectTypeID, objectID, objectUserID, likes, dislikes, cumulativeLikes, cachedReactions) - VALUES (?, ?, ?, ?, ?, ?, ?)"; + (objectTypeID, objectID, objectUserID, likes, cumulativeLikes, cachedReactions) + VALUES (?, ?, ?, ?, ?, ?)"; $insertStatement = WCF::getDB()->prepare($sql); $sql = "UPDATE wcf1_like_object SET objectUserID = ?, likes = ?, - dislikes = 0, cumulativeLikes = ?, cachedReactions = ? WHERE objectTypeID = ? @@ -148,12 +146,10 @@ public function execute() $data['objectUserID'], $existingRow['likes'] + $data['likes'], $existingRow['cumulativeLikes'] + $data['cumulativeLikes'], - \serialize( - $this->mergeCachedReactions( - @\unserialize($existingRow['cachedReactions']), - $data['cachedReactions'] - ) - ), + \json_encode($this->mergeCachedReactions( + $existingRow['cachedReactions'] ? \json_decode($existingRow['cachedReactions'], true, flags: \JSON_THROW_ON_ERROR) : null, + $data['cachedReactions'] + ), \JSON_THROW_ON_ERROR), $objectTypeID, $objectID, ]); @@ -163,9 +159,8 @@ public function execute() $objectID, $data['objectUserID'], $data['likes'], - 0, $data['cumulativeLikes'], - \serialize($data['cachedReactions']), + \json_encode($data['cachedReactions'], \JSON_THROW_ON_ERROR) ]); } } @@ -176,11 +171,11 @@ public function execute() /** * Merges two cached reaction objects into one object. * - * @param int[]|null $oldCachedReactions - * @param int[] $newCachedReactions - * @return int[] + * @param ?array $oldCachedReactions + * @param array $newCachedReactions + * @return array */ - private function mergeCachedReactions($oldCachedReactions, array $newCachedReactions) + private function mergeCachedReactions(?array $oldCachedReactions, array $newCachedReactions): array { if (!\is_array($oldCachedReactions)) { $oldCachedReactions = []; diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index d2e802f53f3..61410b254af 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4209,8 +4209,6 @@ Erlaubte Dateiendungen: gif, jpg, jpeg, png, webp]]> 1} und {/if}{@$users.slice(-1)[0]}{else}{@$users.join(", ")} und {if $others == 1}einem{else}{#$others}{/if} weiteren{/if} gefällt das.{/literal}]]> - - diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index dec59a6450b..3f1735b3065 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4155,8 +4155,6 @@ Allowed extensions: gif, jpg, jpeg, png, webp]]> 1} and {/if}{@$users.slice(-1)[0]}{else}{@$users.join(", ")} and {if $others == 1}one{else}{#$others}{/if} other{if $others > 1}s{/if}{/if} like{if $users.length == 1}s{/if} this.{/literal}]]> - - diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 2b65f9a2c82..9cc2b79e926 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -11,6 +11,7 @@ use wcf\system\database\table\column\DefaultTrueBooleanDatabaseTableColumn; use wcf\system\database\table\column\EnumDatabaseTableColumn; use wcf\system\database\table\column\IntDatabaseTableColumn; +use wcf\system\database\table\column\JsonDatabaseTableColumn; use wcf\system\database\table\column\MediumblobDatabaseTableColumn; use wcf\system\database\table\column\MediumintDatabaseTableColumn; use wcf\system\database\table\column\MediumtextDatabaseTableColumn; @@ -1785,14 +1786,10 @@ MediumintDatabaseTableColumn::create('likes') ->notNull() ->defaultValue(0), - MediumintDatabaseTableColumn::create('dislikes') - ->notNull() - ->defaultValue(0), MediumintDatabaseTableColumn::create('cumulativeLikes') ->notNull() ->defaultValue(0), - TextDatabaseTableColumn::create('cachedUsers'), - TextDatabaseTableColumn::create('cachedReactions'), + JsonDatabaseTableColumn::create('cachedReactions'), ]) ->indices([ DatabaseTablePrimaryIndex::create()