From d4b2bd1ea881c8b8ff22ea3f2a0a2cf62988b8d1 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 11 Mar 2026 16:44:24 +0100 Subject: [PATCH 01/14] Use a data transfer object to provide data for reaction button & summary --- com.woltlab.wcf/templates/reactionButton.tpl | 10 +++ com.woltlab.wcf/templates/reactionSummary.tpl | 6 ++ .../lib/data/TCollectionReactions.class.php | 66 +++++++++++++++++++ .../system/reaction/ReactionData.class.php | 14 ++++ 4 files changed, 96 insertions(+) create mode 100644 com.woltlab.wcf/templates/reactionButton.tpl create mode 100644 com.woltlab.wcf/templates/reactionSummary.tpl create mode 100644 wcfsetup/install/files/lib/data/TCollectionReactions.class.php create mode 100644 wcfsetup/install/files/lib/system/reaction/ReactionData.class.php diff --git a/com.woltlab.wcf/templates/reactionButton.tpl b/com.woltlab.wcf/templates/reactionButton.tpl new file mode 100644 index 0000000000..3eeee92c2c --- /dev/null +++ b/com.woltlab.wcf/templates/reactionButton.tpl @@ -0,0 +1,10 @@ + diff --git a/com.woltlab.wcf/templates/reactionSummary.tpl b/com.woltlab.wcf/templates/reactionSummary.tpl new file mode 100644 index 0000000000..4ca078204a --- /dev/null +++ b/com.woltlab.wcf/templates/reactionSummary.tpl @@ -0,0 +1,6 @@ + diff --git a/wcfsetup/install/files/lib/data/TCollectionReactions.class.php b/wcfsetup/install/files/lib/data/TCollectionReactions.class.php new file mode 100644 index 0000000000..0bd62ed17d --- /dev/null +++ b/wcfsetup/install/files/lib/data/TCollectionReactions.class.php @@ -0,0 +1,66 @@ + + * @since 6.3 + */ +trait TCollectionReactions +{ + /** + * @var array + */ + private array $reactionData; + + public function getReactionData(DatabaseObject $object): ReactionData + { + $this->loadReactionData(); + + return $this->reactionData[$object->getObjectID()]; + } + + public function getCachedReactions(DatabaseObject $object): ?string + { + return $this->getReactionData($object)->cachedReactions; + } + + private function loadReactionData(): void + { + if (isset($this->reactionData)) { + return; + } + + $this->reactionData = []; + + $objectType = ReactionHandler::getInstance()->getObjectType($this->getReactionObjectType()); + ReactionHandler::getInstance()->loadLikeObjects($objectType, $this->getObjectIDs()); + + foreach ($this->getObjectIDs() as $objectID) { + $likeObject = ReactionHandler::getInstance()->getLikeObject($objectType, $objectID); + if ($likeObject !== null) { + $this->reactionData[$objectID] = new ReactionData( + $this->getReactionObjectType(), + $objectID, + $likeObject->reactionTypeID ?: 0, + $likeObject->cachedReactions, + $likeObject->getReactionsJson() + ); + } else { + $this->reactionData[$objectID] = new ReactionData( + $this->getReactionObjectType(), + $objectID, + ); + } + } + } + + protected abstract function getReactionObjectType(): string; +} diff --git a/wcfsetup/install/files/lib/system/reaction/ReactionData.class.php b/wcfsetup/install/files/lib/system/reaction/ReactionData.class.php new file mode 100644 index 0000000000..394d2c85e8 --- /dev/null +++ b/wcfsetup/install/files/lib/system/reaction/ReactionData.class.php @@ -0,0 +1,14 @@ + Date: Thu, 12 Mar 2026 19:17:22 +0100 Subject: [PATCH 02/14] Add RPC endpoints for setting / reverting reactions --- .../Core/Api/Reactions/RevertReaction.ts | 34 +++++ .../Core/Api/Reactions/SetReaction.ts | 39 ++++++ .../core/reactions/RevertReaction.class.php | 110 ++++++++++++++++ .../core/reactions/SetReaction.class.php | 124 ++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 ts/WoltLabSuite/Core/Api/Reactions/RevertReaction.ts create mode 100644 ts/WoltLabSuite/Core/Api/Reactions/SetReaction.ts create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/reactions/RevertReaction.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/reactions/SetReaction.class.php diff --git a/ts/WoltLabSuite/Core/Api/Reactions/RevertReaction.ts b/ts/WoltLabSuite/Core/Api/Reactions/RevertReaction.ts new file mode 100644 index 0000000000..07a61a1d8c --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Reactions/RevertReaction.ts @@ -0,0 +1,34 @@ +/** + * Reverts a reaction on an object. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +type Response = { + reactions: Record; +}; + +export async function revertReaction(objectType: string, objectID: number): Promise> { + const url = new URL(`${window.WSC_RPC_API_URL}core/reactions/revert`); + + let response: Response; + try { + response = (await prepareRequest(url) + .post({ + objectType, + objectID, + }) + .fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/ts/WoltLabSuite/Core/Api/Reactions/SetReaction.ts b/ts/WoltLabSuite/Core/Api/Reactions/SetReaction.ts new file mode 100644 index 0000000000..0076b3cedf --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Reactions/SetReaction.ts @@ -0,0 +1,39 @@ +/** + * Sets a reaction on an object. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +type Response = { + reactions: Record; +}; + +export async function setReaction( + objectType: string, + objectID: number, + reactionTypeID: number, +): Promise> { + const url = new URL(`${window.WSC_RPC_API_URL}core/reactions/set`); + + let response: Response; + try { + response = (await prepareRequest(url) + .post({ + objectType, + objectID, + reactionTypeID, + }) + .fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/reactions/RevertReaction.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/reactions/RevertReaction.class.php new file mode 100644 index 0000000000..80d435cad0 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/reactions/RevertReaction.class.php @@ -0,0 +1,110 @@ + + * @since 6.3 + */ +#[PostRequest('/core/reactions/revert')] +final class RevertReaction implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $parameters = Helper::mapApiParameters($request, RevertReactionParameters::class); + + $this->assertModuleEnabled(); + $this->assertUserCanReact(); + + $objectType = ReactionHandler::getInstance()->getObjectType($parameters->objectType); + if ($objectType === null) { + throw new UserInputException('objectType'); + } + + $objectTypeProvider = $objectType->getProcessor(); + $likeable = $objectTypeProvider->getObjectByID($parameters->objectID); + \assert($likeable instanceof ILikeObject); + $likeable->setObjectType($objectType); + + if ($objectTypeProvider instanceof IRestrictedLikeObjectTypeProvider) { + if (!$objectTypeProvider->canLike($likeable)) { + throw new PermissionDeniedException(); + } + } elseif (!$objectTypeProvider->checkPermissions($likeable)) { + throw new PermissionDeniedException(); + } + + if ($likeable->getUserID() == WCF::getUser()->userID) { + throw new PermissionDeniedException(); + } + + $like = Like::getLike( + $likeable->getObjectType()->objectTypeID, + $likeable->getObjectID(), + WCF::getUser()->userID + ); + + if ($like->likeID) { + (new \wcf\command\reaction\RevertReaction( + $like, + $likeable + ))(); + } + + $likeObject = LikeObject::getLikeObject( + $likeable->getObjectType()->objectTypeID, + $likeable->getObjectID() + ); + + return new JsonResponse([ + 'reactions' => $likeObject->getCachedReactions(), + ]); + } + + private function assertModuleEnabled(): void + { + if (!\MODULE_LIKE) { + throw new IllegalLinkException(); + } + } + + private function assertUserCanReact(): void + { + if (!WCF::getUser()->userID || !WCF::getSession()->getPermission('user.like.canLike')) { + throw new PermissionDeniedException(); + } + } +} + +/** @internal */ +final class RevertReactionParameters +{ + public function __construct( + /** @var positive-int */ + public readonly int $objectID, + + /** @var non-empty-string */ + public readonly string $objectType, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/reactions/SetReaction.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/reactions/SetReaction.class.php new file mode 100644 index 0000000000..f7b5e963ee --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/reactions/SetReaction.class.php @@ -0,0 +1,124 @@ + + * @since 6.3 + */ +#[PostRequest('/core/reactions/set')] +final class SetReaction implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $parameters = Helper::mapApiParameters($request, SetReactionParameters::class); + + $this->assertModuleEnabled(); + $this->assertUserCanReact(); + + $objectType = ReactionHandler::getInstance()->getObjectType($parameters->objectType); + if ($objectType === null) { + throw new UserInputException('objectType'); + } + + $reactionType = ReactionTypeCache::getInstance()->getReactionTypeByID($parameters->reactionTypeID); + if (!$reactionType->reactionTypeID) { + throw new UserInputException('reactionTypeID'); + } + + $objectTypeProvider = $objectType->getProcessor(); + $likeable = $objectTypeProvider->getObjectByID($parameters->objectID); + \assert($likeable instanceof ILikeObject); + $likeable->setObjectType($objectType); + + if ($objectTypeProvider instanceof IRestrictedLikeObjectTypeProvider) { + if (!$objectTypeProvider->canLike($likeable)) { + throw new PermissionDeniedException(); + } + } elseif (!$objectTypeProvider->checkPermissions($likeable)) { + throw new PermissionDeniedException(); + } + + if ($likeable->getUserID() == WCF::getUser()->userID) { + throw new PermissionDeniedException(); + } + + if (!$reactionType->isAssignable) { + throw new UserInputException('reactionTypeID'); + } + + $like = Like::getLike( + $likeable->getObjectType()->objectTypeID, + $likeable->getObjectID(), + WCF::getUser()->userID + ); + + if (!$like->likeID || $like->reactionTypeID !== $reactionType->reactionTypeID) { + (new \wcf\command\reaction\SetReaction( + $likeable, + WCF::getUser(), + $reactionType + ))(); + } + + $likeObject = LikeObject::getLikeObject( + $likeable->getObjectType()->objectTypeID, + $likeable->getObjectID() + ); + + return new JsonResponse([ + 'reactions' => $likeObject->getCachedReactions(), + ]); + } + + private function assertModuleEnabled(): void + { + if (!\MODULE_LIKE) { + throw new IllegalLinkException(); + } + } + + private function assertUserCanReact(): void + { + if (!WCF::getUser()->userID || !WCF::getSession()->getPermission('user.like.canLike')) { + throw new PermissionDeniedException(); + } + } +} + +/** @internal */ +final class SetReactionParameters +{ + public function __construct( + /** @var positive-int */ + public readonly int $objectID, + + /** @var non-empty-string */ + public readonly string $objectType, + + /** @var positive-int */ + public readonly int $reactionTypeID, + ) {} +} From 56f5c6e0165e049d049089b9f242550be642ef73 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 12 Mar 2026 19:18:57 +0100 Subject: [PATCH 03/14] Refactor `WoltLabSuite/Core/Ui/Reaction/Handler` --- ts/WoltLabSuite/Core/BootstrapFrontend.ts | 3 + .../Core/Component/Reaction/Button.ts | 252 ++++++++++++++++++ ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts | 1 + .../Core/Api/Reactions/RevertReaction.js | 30 +++ .../Core/Api/Reactions/SetReaction.js | 31 +++ .../js/WoltLabSuite/Core/BootstrapFrontend.js | 3 + .../Core/Component/Reaction/Button.js | 177 ++++++++++++ .../WoltLabSuite/Core/Ui/Reaction/Handler.js | 1 + 8 files changed, 498 insertions(+) create mode 100644 ts/WoltLabSuite/Core/Component/Reaction/Button.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/RevertReaction.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/SetReaction.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js diff --git a/ts/WoltLabSuite/Core/BootstrapFrontend.ts b/ts/WoltLabSuite/Core/BootstrapFrontend.ts index 9f56aba635..034c889f5c 100644 --- a/ts/WoltLabSuite/Core/BootstrapFrontend.ts +++ b/ts/WoltLabSuite/Core/BootstrapFrontend.ts @@ -169,4 +169,7 @@ export function setup(options: BootstrapOptions): void { whenFirstSeen("[data-report-content]", () => { void import("./Ui/Moderation/Report").then(({ setup }) => setup(options.reportEndpoint)); }); + whenFirstSeen("[data-reaction-object-type]", () => { + void import("./Component/Reaction/Button").then(({ setup }) => setup()); + }); } diff --git a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts new file mode 100644 index 0000000000..f6231cc519 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts @@ -0,0 +1,252 @@ +/** + * Handles the reactions buttons. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ + +import { revertReaction } from "WoltLabSuite/Core/Api/Reactions/RevertReaction"; +import { setReaction } from "WoltLabSuite/Core/Api/Reactions/SetReaction"; +import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import { Reaction } from "WoltLabSuite/Core/Ui/Reaction/Data"; +import DomChangeListener from "WoltLabSuite/Core/Dom/Change/Listener"; +import { createFocusTrap, FocusTrap } from "focus-trap"; +import * as UiAlignment from "WoltLabSuite/Core/Ui/Alignment"; +import * as UiScreen from "WoltLabSuite/Core/Ui/Screen"; +import UiCloseOverlay from "WoltLabSuite/Core/Ui/CloseOverlay"; + +type Result = + | { + ok: true; + reactionTypeId: number; + } + | { + ok: false; + }; + +const availableReactions = Object.values(window.REACTION_TYPES); + +class ReactionPopover { + #resolve?: (value: Result) => void; + #popover?: HTMLElement; + #focusTrap?: FocusTrap; + + open(button: HTMLButtonElement): Promise { + if (this.#resolve !== undefined) { + this.#resolve({ ok: false }); + } + + this.#showPopover(button); + + return new Promise((resolve) => { + this.#resolve = resolve; + }); + } + + cancel(): void { + if (this.#resolve !== undefined) { + this.#resolve({ ok: false }); + } + this.#resolve = undefined; + this.#hidePopover(); + } + + #click(reactionTypeId: number): void { + if (this.#resolve !== undefined) { + this.#resolve({ ok: true, reactionTypeId }); + } + this.#resolve = undefined; + this.#hidePopover(); + } + + #createPopover(): void { + if (this.#popover !== undefined) { + return; + } + + this.#popover = document.createElement("div"); + this.#popover.className = "reactionPopover forceHide"; + + const popoverContent = document.createElement("div"); + popoverContent.className = "reactionPopoverContent"; + + this.#getSortedReactionTypes().forEach((reactionType) => { + const reactionTypeButton = document.createElement("button"); + reactionTypeButton.type = "button"; + reactionTypeButton.tabIndex = 0; + reactionTypeButton.className = "reactionTypeButton jsTooltip"; + reactionTypeButton.title = reactionType.title; + reactionTypeButton.dataset.reactionTypeId = reactionType.reactionTypeID.toString(); + reactionTypeButton.dataset.isAssignable = reactionType.isAssignable.toString(); + reactionTypeButton.innerHTML = reactionType.renderedIcon; + reactionTypeButton.addEventListener("click", () => this.#click(reactionType.reactionTypeID)); + //reactionTypeItem.addEventListener("keydown", (ev) => this.keydown(ev)); + + if (!reactionType.isAssignable) { + reactionTypeButton.hidden = true; + } + + popoverContent.appendChild(reactionTypeButton); + }); + + this.#popover.appendChild(popoverContent); + + document.body.appendChild(this.#popover); + + UiCloseOverlay.add("WoltLabSuite/Core/Component/Reaction/Button", () => this.cancel()); + + DomChangeListener.trigger(); + } + + #showPopover(button: HTMLButtonElement): void { + const popover = this.#getPopover(); + + UiAlignment.set(popover, button, { + horizontal: "center", + vertical: UiScreen.is("screen-xs") ? "bottom" : "top", + }); + + // The popover could be rendered below the input field on mobile, in which case + // the "first" button is displayed at the bottom and thus farthest away. Reversing + // the display order will restore the logic by placing the "first" button as close + // to the react button as possible. + const inverseOrder = popover.style.getPropertyValue("bottom") === "auto"; + if (inverseOrder) { + popover.classList.add("inverseOrder"); + } else { + popover.classList.remove("inverseOrder"); + } + + popover.querySelectorAll(".reactionTypeButton.active").forEach((element: HTMLElement) => { + element.classList.remove("active"); + }); + + if (parseInt(button.dataset.reactionTypeId!)) { + const reactionTypeButton = popover.querySelector( + `.reactionTypeButton[data-reaction-type-id="${button.dataset.reactionTypeId}"]`, + ) as HTMLButtonElement; + reactionTypeButton.classList.add("active"); + reactionTypeButton.hidden = false; + } + + popover.classList.remove("forceHide"); + popover.classList.add("active"); + + this.#getFocusTrap().activate(); + } + + #hidePopover(): void { + const popover = this.#getPopover(); + popover.classList.remove("active"); + + popover + .querySelectorAll('.reactionTypeButton[data-is-assignable="0"]') + .forEach((button: HTMLButtonElement) => (button.hidden = true)); + + this.#getFocusTrap().deactivate(); + } + + #getSortedReactionTypes(): Reaction[] { + return availableReactions.sort((a, b) => a.showOrder - b.showOrder); + } + + #getPopover(): HTMLElement { + if (this.#popover === undefined) { + this.#createPopover(); + } + + return this.#popover!; + } + + #getFocusTrap(): FocusTrap { + if (this.#focusTrap === undefined) { + this.#focusTrap = createFocusTrap(this.#getPopover(), { + allowOutsideClick: true, + escapeDeactivates: (): boolean => { + this.#hidePopover(); + + return false; + }, + preventScroll: true, + }); + } + + return this.#focusTrap; + } +} + +function updateReactionSummary( + objectType: string, + objectId: number, + cachedReactions: Record, + selectedReaction?: number, +): void { + const reactions = new Map(); + Object.entries(cachedReactions).forEach(([key, value]) => { + reactions.set(parseInt(key), value); + }); + + const component = document.querySelector( + `woltlab-core-reaction-summary[object-type="${objectType}"][object-id="${objectId}"]`, + ) as WoltlabCoreReactionSummaryElement; + component?.setData(reactions, selectedReaction); +} + +export function setup(): void { + const reactionPopover = new ReactionPopover(); + + wheneverFirstSeen("[data-reaction-object-type]", (button: HTMLButtonElement) => { + let isOpen = false; + button.addEventListener("click", (event) => { + event.stopPropagation(); // Necessary so that `Ui/CloseOverlay` does not close the popover immediately + + if (isOpen) { + reactionPopover.cancel(); + isOpen = false; + return; + } + + isOpen = true; + void reactionPopover.open(button).then(async (result: Result) => { + isOpen = false; + + if (!result.ok) { + return; + } + + const oldReactionTypeId = parseInt(button.dataset.reactionTypeId!); + + if (result.reactionTypeId == oldReactionTypeId) { + const response = await revertReaction(button.dataset.reactionObjectType!, parseInt(button.dataset.objectId!)); + button.dataset.reactionTypeId = "0"; + button.classList.remove("active"); + + updateReactionSummary( + button.dataset.reactionObjectType!, + parseInt(button.dataset.objectId!), + response.unwrap().reactions, + result.reactionTypeId, + ); + } else { + const response = await setReaction( + button.dataset.reactionObjectType!, + parseInt(button.dataset.objectId!), + result.reactionTypeId, + ); + button.dataset.reactionTypeId = result.reactionTypeId.toString(); + button.classList.add("active"); + + updateReactionSummary( + button.dataset.reactionObjectType!, + parseInt(button.dataset.objectId!), + response.unwrap().reactions, + result.reactionTypeId, + ); + } + }); + }); + }); +} diff --git a/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts b/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts index 6c3de17677..3e340588e9 100644 --- a/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts +++ b/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts @@ -5,6 +5,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * @since 5.2 + * @deprecated 6.3 Use `WoltLabSuite/Core/Component/Reaction/Button` instead. */ import * as Ajax from "../../Ajax"; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/RevertReaction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/RevertReaction.js new file mode 100644 index 0000000000..7cff672a26 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/RevertReaction.js @@ -0,0 +1,30 @@ +/** + * Reverts a reaction on an object. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.revertReaction = revertReaction; + async function revertReaction(objectType, objectID) { + const url = new URL(`${window.WSC_RPC_API_URL}core/reactions/revert`); + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url) + .post({ + objectType, + objectID, + }) + .fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/SetReaction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/SetReaction.js new file mode 100644 index 0000000000..2314daae52 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/SetReaction.js @@ -0,0 +1,31 @@ +/** + * Sets a reaction on an object. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setReaction = setReaction; + async function setReaction(objectType, objectID, reactionTypeID) { + const url = new URL(`${window.WSC_RPC_API_URL}core/reactions/set`); + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url) + .post({ + objectType, + objectID, + reactionTypeID, + }) + .fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js b/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js index e4390a1280..1c645ee9a1 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js @@ -123,5 +123,8 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui (0, LazyLoader_1.whenFirstSeen)("[data-report-content]", () => { void new Promise((resolve_11, reject_11) => { require(["./Ui/Moderation/Report"], resolve_11, reject_11); }).then(tslib_1.__importStar).then(({ setup }) => setup(options.reportEndpoint)); }); + (0, LazyLoader_1.whenFirstSeen)("[data-reaction-object-type]", () => { + void new Promise((resolve_12, reject_12) => { require(["./Component/Reaction/Button"], resolve_12, reject_12); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + }); } }); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js new file mode 100644 index 0000000000..f9bbd5ef89 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js @@ -0,0 +1,177 @@ +/** + * Handles the reactions buttons. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertReaction", "WoltLabSuite/Core/Api/Reactions/SetReaction", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Dom/Change/Listener", "focus-trap", "WoltLabSuite/Core/Ui/Alignment", "WoltLabSuite/Core/Ui/Screen", "WoltLabSuite/Core/Ui/CloseOverlay"], function (require, exports, tslib_1, RevertReaction_1, SetReaction_1, Selector_1, Listener_1, focus_trap_1, UiAlignment, UiScreen, CloseOverlay_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + Listener_1 = tslib_1.__importDefault(Listener_1); + UiAlignment = tslib_1.__importStar(UiAlignment); + UiScreen = tslib_1.__importStar(UiScreen); + CloseOverlay_1 = tslib_1.__importDefault(CloseOverlay_1); + const availableReactions = Object.values(window.REACTION_TYPES); + class ReactionPopover { + #resolve; + #popover; + #focusTrap; + open(button) { + if (this.#resolve !== undefined) { + this.#resolve({ ok: false }); + } + this.#showPopover(button); + return new Promise((resolve) => { + this.#resolve = resolve; + }); + } + cancel() { + if (this.#resolve !== undefined) { + this.#resolve({ ok: false }); + } + this.#resolve = undefined; + this.#hidePopover(); + } + #click(reactionTypeId) { + if (this.#resolve !== undefined) { + this.#resolve({ ok: true, reactionTypeId }); + } + this.#resolve = undefined; + this.#hidePopover(); + } + #createPopover() { + if (this.#popover !== undefined) { + return; + } + this.#popover = document.createElement("div"); + this.#popover.className = "reactionPopover forceHide"; + const popoverContent = document.createElement("div"); + popoverContent.className = "reactionPopoverContent"; + this.#getSortedReactionTypes().forEach((reactionType) => { + const reactionTypeButton = document.createElement("button"); + reactionTypeButton.type = "button"; + reactionTypeButton.tabIndex = 0; + reactionTypeButton.className = "reactionTypeButton jsTooltip"; + reactionTypeButton.title = reactionType.title; + reactionTypeButton.dataset.reactionTypeId = reactionType.reactionTypeID.toString(); + reactionTypeButton.dataset.isAssignable = reactionType.isAssignable.toString(); + reactionTypeButton.innerHTML = reactionType.renderedIcon; + reactionTypeButton.addEventListener("click", () => this.#click(reactionType.reactionTypeID)); + //reactionTypeItem.addEventListener("keydown", (ev) => this.keydown(ev)); + if (!reactionType.isAssignable) { + reactionTypeButton.hidden = true; + } + popoverContent.appendChild(reactionTypeButton); + }); + this.#popover.appendChild(popoverContent); + document.body.appendChild(this.#popover); + CloseOverlay_1.default.add("WoltLabSuite/Core/Component/Reaction/Button", () => this.cancel()); + Listener_1.default.trigger(); + } + #showPopover(button) { + const popover = this.#getPopover(); + UiAlignment.set(popover, button, { + horizontal: "center", + vertical: UiScreen.is("screen-xs") ? "bottom" : "top", + }); + // The popover could be rendered below the input field on mobile, in which case + // the "first" button is displayed at the bottom and thus farthest away. Reversing + // the display order will restore the logic by placing the "first" button as close + // to the react button as possible. + const inverseOrder = popover.style.getPropertyValue("bottom") === "auto"; + if (inverseOrder) { + popover.classList.add("inverseOrder"); + } + else { + popover.classList.remove("inverseOrder"); + } + popover.querySelectorAll(".reactionTypeButton.active").forEach((element) => { + element.classList.remove("active"); + }); + if (parseInt(button.dataset.reactionTypeId)) { + const reactionTypeButton = popover.querySelector(`.reactionTypeButton[data-reaction-type-id="${button.dataset.reactionTypeId}"]`); + reactionTypeButton.classList.add("active"); + reactionTypeButton.hidden = false; + } + popover.classList.remove("forceHide"); + popover.classList.add("active"); + this.#getFocusTrap().activate(); + } + #hidePopover() { + const popover = this.#getPopover(); + popover.classList.remove("active"); + popover + .querySelectorAll('.reactionTypeButton[data-is-assignable="0"]') + .forEach((button) => (button.hidden = true)); + this.#getFocusTrap().deactivate(); + } + #getSortedReactionTypes() { + return availableReactions.sort((a, b) => a.showOrder - b.showOrder); + } + #getPopover() { + if (this.#popover === undefined) { + this.#createPopover(); + } + return this.#popover; + } + #getFocusTrap() { + if (this.#focusTrap === undefined) { + this.#focusTrap = (0, focus_trap_1.createFocusTrap)(this.#getPopover(), { + allowOutsideClick: true, + escapeDeactivates: () => { + this.#hidePopover(); + return false; + }, + preventScroll: true, + }); + } + return this.#focusTrap; + } + } + function updateReactionSummary(objectType, objectId, cachedReactions, selectedReaction) { + const reactions = new Map(); + Object.entries(cachedReactions).forEach(([key, value]) => { + reactions.set(parseInt(key), value); + }); + const component = document.querySelector(`woltlab-core-reaction-summary[object-type="${objectType}"][object-id="${objectId}"]`); + component?.setData(reactions, selectedReaction); + } + function setup() { + const reactionPopover = new ReactionPopover(); + (0, Selector_1.wheneverFirstSeen)("[data-reaction-object-type]", (button) => { + let isOpen = false; + button.addEventListener("click", (event) => { + event.stopPropagation(); // Necessary so that `Ui/CloseOverlay` does not close the popover immediately + if (isOpen) { + reactionPopover.cancel(); + isOpen = false; + return; + } + isOpen = true; + void reactionPopover.open(button).then(async (result) => { + isOpen = false; + if (!result.ok) { + return; + } + const oldReactionTypeId = parseInt(button.dataset.reactionTypeId); + if (result.reactionTypeId == oldReactionTypeId) { + const response = await (0, RevertReaction_1.revertReaction)(button.dataset.reactionObjectType, parseInt(button.dataset.objectId)); + button.dataset.reactionTypeId = "0"; + button.classList.remove("active"); + updateReactionSummary(button.dataset.reactionObjectType, parseInt(button.dataset.objectId), response.unwrap().reactions, result.reactionTypeId); + } + else { + const response = await (0, SetReaction_1.setReaction)(button.dataset.reactionObjectType, parseInt(button.dataset.objectId), result.reactionTypeId); + button.dataset.reactionTypeId = result.reactionTypeId.toString(); + button.classList.add("active"); + updateReactionSummary(button.dataset.reactionObjectType, parseInt(button.dataset.objectId), response.unwrap().reactions, result.reactionTypeId); + } + }); + }); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js index 570f80ac07..0d3ec5e712 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js @@ -5,6 +5,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * @since 5.2 + * @deprecated 6.3 Use `WoltLabSuite/Core/Component/Reaction/Button` instead. */ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Change/Listener", "../../Dom/Util", "../Alignment", "../CloseOverlay", "../Screen", "focus-trap"], function (require, exports, tslib_1, Ajax, Core, Listener_1, Util_1, UiAlignment, CloseOverlay_1, UiScreen, focus_trap_1) { "use strict"; From ca5e82234fee50030b2c6f501c97103f8611d29d Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 12 Mar 2026 19:21:51 +0100 Subject: [PATCH 04/14] Hide popover on windows resize --- ts/WoltLabSuite/Core/Component/Reaction/Button.ts | 9 ++++++++- .../js/WoltLabSuite/Core/Component/Reaction/Button.js | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts index f6231cc519..e7bb3b41ee 100644 --- a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts +++ b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts @@ -83,7 +83,6 @@ class ReactionPopover { reactionTypeButton.dataset.isAssignable = reactionType.isAssignable.toString(); reactionTypeButton.innerHTML = reactionType.renderedIcon; reactionTypeButton.addEventListener("click", () => this.#click(reactionType.reactionTypeID)); - //reactionTypeItem.addEventListener("keydown", (ev) => this.keydown(ev)); if (!reactionType.isAssignable) { reactionTypeButton.hidden = true; @@ -98,6 +97,14 @@ class ReactionPopover { UiCloseOverlay.add("WoltLabSuite/Core/Component/Reaction/Button", () => this.cancel()); + window.addEventListener( + "resize", + () => { + this.cancel(); + }, + { passive: true }, + ); + DomChangeListener.trigger(); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js index f9bbd5ef89..127029e8a5 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js @@ -61,7 +61,6 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe reactionTypeButton.dataset.isAssignable = reactionType.isAssignable.toString(); reactionTypeButton.innerHTML = reactionType.renderedIcon; reactionTypeButton.addEventListener("click", () => this.#click(reactionType.reactionTypeID)); - //reactionTypeItem.addEventListener("keydown", (ev) => this.keydown(ev)); if (!reactionType.isAssignable) { reactionTypeButton.hidden = true; } @@ -70,6 +69,9 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe this.#popover.appendChild(popoverContent); document.body.appendChild(this.#popover); CloseOverlay_1.default.add("WoltLabSuite/Core/Component/Reaction/Button", () => this.cancel()); + window.addEventListener("resize", () => { + this.cancel(); + }, { passive: true }); Listener_1.default.trigger(); } #showPopover(button) { From 22c9ffca752066f2edf9f902e3ed2ab120dfb8f0 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 12 Mar 2026 19:54:24 +0100 Subject: [PATCH 05/14] Simplify API calls --- .../Core/Api/Reactions/RevertReaction.ts | 19 +++++---------- .../Core/Api/Reactions/SetReaction.ts | 23 +++++-------------- .../Core/Api/Reactions/RevertReaction.js | 14 ++++------- .../Core/Api/Reactions/SetReaction.js | 14 ++++------- 4 files changed, 20 insertions(+), 50 deletions(-) diff --git a/ts/WoltLabSuite/Core/Api/Reactions/RevertReaction.ts b/ts/WoltLabSuite/Core/Api/Reactions/RevertReaction.ts index 07a61a1d8c..b5993923a5 100644 --- a/ts/WoltLabSuite/Core/Api/Reactions/RevertReaction.ts +++ b/ts/WoltLabSuite/Core/Api/Reactions/RevertReaction.ts @@ -9,26 +9,19 @@ */ import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; -import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; +import { fromInfallibleApiRequest } from "../Result"; type Response = { reactions: Record; }; -export async function revertReaction(objectType: string, objectID: number): Promise> { - const url = new URL(`${window.WSC_RPC_API_URL}core/reactions/revert`); - - let response: Response; - try { - response = (await prepareRequest(url) +export async function revertReaction(objectType: string, objectID: number): Promise { + return fromInfallibleApiRequest(() => { + return prepareRequest(`${window.WSC_RPC_API_URL}core/reactions/revert`) .post({ objectType, objectID, }) - .fetchAsJson()) as Response; - } catch (e) { - return apiResultFromError(e); - } - - return apiResultFromValue(response); + .fetchAsJson(); + }); } diff --git a/ts/WoltLabSuite/Core/Api/Reactions/SetReaction.ts b/ts/WoltLabSuite/Core/Api/Reactions/SetReaction.ts index 0076b3cedf..b364047010 100644 --- a/ts/WoltLabSuite/Core/Api/Reactions/SetReaction.ts +++ b/ts/WoltLabSuite/Core/Api/Reactions/SetReaction.ts @@ -9,31 +9,20 @@ */ import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; -import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; +import { fromInfallibleApiRequest } from "../Result"; type Response = { reactions: Record; }; -export async function setReaction( - objectType: string, - objectID: number, - reactionTypeID: number, -): Promise> { - const url = new URL(`${window.WSC_RPC_API_URL}core/reactions/set`); - - let response: Response; - try { - response = (await prepareRequest(url) +export async function setReaction(objectType: string, objectID: number, reactionTypeID: number): Promise { + return fromInfallibleApiRequest(() => { + return prepareRequest(`${window.WSC_RPC_API_URL}core/reactions/set`) .post({ objectType, objectID, reactionTypeID, }) - .fetchAsJson()) as Response; - } catch (e) { - return apiResultFromError(e); - } - - return apiResultFromValue(response); + .fetchAsJson(); + }); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/RevertReaction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/RevertReaction.js index 7cff672a26..672add7c8a 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/RevertReaction.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/RevertReaction.js @@ -12,19 +12,13 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu Object.defineProperty(exports, "__esModule", { value: true }); exports.revertReaction = revertReaction; async function revertReaction(objectType, objectID) { - const url = new URL(`${window.WSC_RPC_API_URL}core/reactions/revert`); - let response; - try { - response = (await (0, Backend_1.prepareRequest)(url) + return (0, Result_1.fromInfallibleApiRequest)(() => { + return (0, Backend_1.prepareRequest)(`${window.WSC_RPC_API_URL}core/reactions/revert`) .post({ objectType, objectID, }) - .fetchAsJson()); - } - catch (e) { - return (0, Result_1.apiResultFromError)(e); - } - return (0, Result_1.apiResultFromValue)(response); + .fetchAsJson(); + }); } }); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/SetReaction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/SetReaction.js index 2314daae52..60b9073330 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/SetReaction.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Reactions/SetReaction.js @@ -12,20 +12,14 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu Object.defineProperty(exports, "__esModule", { value: true }); exports.setReaction = setReaction; async function setReaction(objectType, objectID, reactionTypeID) { - const url = new URL(`${window.WSC_RPC_API_URL}core/reactions/set`); - let response; - try { - response = (await (0, Backend_1.prepareRequest)(url) + return (0, Result_1.fromInfallibleApiRequest)(() => { + return (0, Backend_1.prepareRequest)(`${window.WSC_RPC_API_URL}core/reactions/set`) .post({ objectType, objectID, reactionTypeID, }) - .fetchAsJson()); - } - catch (e) { - return (0, Result_1.apiResultFromError)(e); - } - return (0, Result_1.apiResultFromValue)(response); + .fetchAsJson(); + }); } }); From b6fdef2cd63b6483177be86eda01b7abbca75a10 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 12 Mar 2026 19:57:44 +0100 Subject: [PATCH 06/14] Add support for simple toggle button in case only one reaction type is available --- com.woltlab.wcf/templates/reactionButton.tpl | 1 + .../Core/Component/Reaction/Button.ts | 75 +++++++++++++------ .../Core/Component/Reaction/Button.js | 53 +++++++++++-- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/com.woltlab.wcf/templates/reactionButton.tpl b/com.woltlab.wcf/templates/reactionButton.tpl index 3eeee92c2c..54a874f6bb 100644 --- a/com.woltlab.wcf/templates/reactionButton.tpl +++ b/com.woltlab.wcf/templates/reactionButton.tpl @@ -2,6 +2,7 @@ type="button" class="reactionButton jsTooltip button{if $reactionData->reactionTypeID} active{/if}" title="{lang}wcf.reactions.react{/lang}" + aria-pressed="{if $reactionData->reactionTypeID}true{else}false{/if}" data-reaction-type-id="{$reactionData->reactionTypeID}" data-reaction-object-type="{$reactionData->objectType}" data-object-id="{$reactionData->objectID}" diff --git a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts index e7bb3b41ee..83d785d534 100644 --- a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts +++ b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts @@ -17,6 +17,7 @@ import { createFocusTrap, FocusTrap } from "focus-trap"; import * as UiAlignment from "WoltLabSuite/Core/Ui/Alignment"; import * as UiScreen from "WoltLabSuite/Core/Ui/Screen"; import UiCloseOverlay from "WoltLabSuite/Core/Ui/CloseOverlay"; +import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; type Result = | { @@ -202,7 +203,38 @@ function updateReactionSummary( component?.setData(reactions, selectedReaction); } -export function setup(): void { +function setupToggleButton(): void { + wheneverFirstSeen("[data-reaction-object-type]", (button: HTMLButtonElement) => { + button.addEventListener( + "click", + promiseMutex(() => toggleButton(button)), + ); + }); +} + +async function toggleButton(button: HTMLButtonElement): Promise { + const objectId = parseInt(button.dataset.objectId!); + const objectType = button.dataset.reactionObjectType!; + let reactionTypeId: number = 0; + let reactions: Record; + + if (button.classList.contains("active")) { + reactions = (await revertReaction(objectType, objectId)).reactions; + button.dataset.reactionTypeId = "0"; + button.classList.remove("active"); + button.setAttribute("aria-pressed", "false"); + } else { + reactionTypeId = availableReactions[0].reactionTypeID; + reactions = (await setReaction(objectType, objectId, reactionTypeId)).reactions; + button.dataset.reactionTypeId = reactionTypeId.toString(); + button.classList.add("active"); + button.setAttribute("aria-pressed", "true"); + } + + updateReactionSummary(objectType, objectId, reactions, reactionTypeId); +} + +function setupPopoverButton(): void { const reactionPopover = new ReactionPopover(); wheneverFirstSeen("[data-reaction-object-type]", (button: HTMLButtonElement) => { @@ -225,35 +257,34 @@ export function setup(): void { } const oldReactionTypeId = parseInt(button.dataset.reactionTypeId!); + const objectId = parseInt(button.dataset.objectId!); + const objectType = button.dataset.reactionObjectType!; + let reactionTypeId: number = 0; + let reactions: Record; if (result.reactionTypeId == oldReactionTypeId) { - const response = await revertReaction(button.dataset.reactionObjectType!, parseInt(button.dataset.objectId!)); + reactions = (await revertReaction(objectType, objectId)).reactions; button.dataset.reactionTypeId = "0"; button.classList.remove("active"); - - updateReactionSummary( - button.dataset.reactionObjectType!, - parseInt(button.dataset.objectId!), - response.unwrap().reactions, - result.reactionTypeId, - ); + button.setAttribute("aria-pressed", "false"); } else { - const response = await setReaction( - button.dataset.reactionObjectType!, - parseInt(button.dataset.objectId!), - result.reactionTypeId, - ); - button.dataset.reactionTypeId = result.reactionTypeId.toString(); + reactionTypeId = result.reactionTypeId; + reactions = (await setReaction(objectType, objectId, reactionTypeId)).reactions; + button.dataset.reactionTypeId = reactionTypeId.toString(); button.classList.add("active"); - - updateReactionSummary( - button.dataset.reactionObjectType!, - parseInt(button.dataset.objectId!), - response.unwrap().reactions, - result.reactionTypeId, - ); + button.setAttribute("aria-pressed", "true"); } + + updateReactionSummary(objectType, objectId, reactions, reactionTypeId); }); }); }); } + +export function setup(): void { + if (availableReactions.length === 1) { + setupToggleButton(); + } else { + setupPopoverButton(); + } +} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js index 127029e8a5..91ecd25ef4 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js @@ -7,7 +7,7 @@ * @since 6.3 * @woltlabExcludeBundle tiny */ -define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertReaction", "WoltLabSuite/Core/Api/Reactions/SetReaction", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Dom/Change/Listener", "focus-trap", "WoltLabSuite/Core/Ui/Alignment", "WoltLabSuite/Core/Ui/Screen", "WoltLabSuite/Core/Ui/CloseOverlay"], function (require, exports, tslib_1, RevertReaction_1, SetReaction_1, Selector_1, Listener_1, focus_trap_1, UiAlignment, UiScreen, CloseOverlay_1) { +define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertReaction", "WoltLabSuite/Core/Api/Reactions/SetReaction", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Dom/Change/Listener", "focus-trap", "WoltLabSuite/Core/Ui/Alignment", "WoltLabSuite/Core/Ui/Screen", "WoltLabSuite/Core/Ui/CloseOverlay", "WoltLabSuite/Core/Helper/PromiseMutex"], function (require, exports, tslib_1, RevertReaction_1, SetReaction_1, Selector_1, Listener_1, focus_trap_1, UiAlignment, UiScreen, CloseOverlay_1, PromiseMutex_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; @@ -142,7 +142,32 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe const component = document.querySelector(`woltlab-core-reaction-summary[object-type="${objectType}"][object-id="${objectId}"]`); component?.setData(reactions, selectedReaction); } - function setup() { + function setupToggleButton() { + (0, Selector_1.wheneverFirstSeen)("[data-reaction-object-type]", (button) => { + button.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => toggleButton(button))); + }); + } + async function toggleButton(button) { + const objectId = parseInt(button.dataset.objectId); + const objectType = button.dataset.reactionObjectType; + let reactionTypeId = 0; + let reactions; + if (button.classList.contains("active")) { + reactions = (await (0, RevertReaction_1.revertReaction)(objectType, objectId)).reactions; + button.dataset.reactionTypeId = "0"; + button.classList.remove("active"); + button.setAttribute("aria-pressed", "false"); + } + else { + reactionTypeId = availableReactions[0].reactionTypeID; + reactions = (await (0, SetReaction_1.setReaction)(objectType, objectId, reactionTypeId)).reactions; + button.dataset.reactionTypeId = reactionTypeId.toString(); + button.classList.add("active"); + button.setAttribute("aria-pressed", "true"); + } + updateReactionSummary(objectType, objectId, reactions, reactionTypeId); + } + function setupPopoverButton() { const reactionPopover = new ReactionPopover(); (0, Selector_1.wheneverFirstSeen)("[data-reaction-object-type]", (button) => { let isOpen = false; @@ -160,20 +185,34 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe return; } const oldReactionTypeId = parseInt(button.dataset.reactionTypeId); + const objectId = parseInt(button.dataset.objectId); + const objectType = button.dataset.reactionObjectType; + let reactionTypeId = 0; + let reactions; if (result.reactionTypeId == oldReactionTypeId) { - const response = await (0, RevertReaction_1.revertReaction)(button.dataset.reactionObjectType, parseInt(button.dataset.objectId)); + reactions = (await (0, RevertReaction_1.revertReaction)(objectType, objectId)).reactions; button.dataset.reactionTypeId = "0"; button.classList.remove("active"); - updateReactionSummary(button.dataset.reactionObjectType, parseInt(button.dataset.objectId), response.unwrap().reactions, result.reactionTypeId); + button.setAttribute("aria-pressed", "false"); } else { - const response = await (0, SetReaction_1.setReaction)(button.dataset.reactionObjectType, parseInt(button.dataset.objectId), result.reactionTypeId); - button.dataset.reactionTypeId = result.reactionTypeId.toString(); + reactionTypeId = result.reactionTypeId; + reactions = (await (0, SetReaction_1.setReaction)(objectType, objectId, reactionTypeId)).reactions; + button.dataset.reactionTypeId = reactionTypeId.toString(); button.classList.add("active"); - updateReactionSummary(button.dataset.reactionObjectType, parseInt(button.dataset.objectId), response.unwrap().reactions, result.reactionTypeId); + button.setAttribute("aria-pressed", "true"); } + updateReactionSummary(objectType, objectId, reactions, reactionTypeId); }); }); }); } + function setup() { + if (availableReactions.length === 1) { + setupToggleButton(); + } + else { + setupPopoverButton(); + } + } }); From 2ddb0e1aae8dabccd975db4afafa3de7a67d2d11 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 12 Mar 2026 20:17:42 +0100 Subject: [PATCH 07/14] Improve a11y --- ts/WoltLabSuite/Core/Component/Reaction/Button.ts | 13 +++++++++++++ .../WoltLabSuite/Core/Component/Reaction/Button.js | 13 ++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts index 83d785d534..305832c02d 100644 --- a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts +++ b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts @@ -18,6 +18,7 @@ import * as UiAlignment from "WoltLabSuite/Core/Ui/Alignment"; import * as UiScreen from "WoltLabSuite/Core/Ui/Screen"; import UiCloseOverlay from "WoltLabSuite/Core/Ui/CloseOverlay"; import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; +import { getPhrase } from "WoltLabSuite/Core/Language"; type Result = | { @@ -70,12 +71,17 @@ class ReactionPopover { this.#popover = document.createElement("div"); this.#popover.className = "reactionPopover forceHide"; + this.#popover.setAttribute("role", "listbox"); + this.#popover.setAttribute("aria-orientation", "horizontal"); + this.#popover.setAttribute("aria-label", getPhrase("wcf.reactions.react")); const popoverContent = document.createElement("div"); popoverContent.className = "reactionPopoverContent"; this.#getSortedReactionTypes().forEach((reactionType) => { const reactionTypeButton = document.createElement("button"); + reactionTypeButton.setAttribute("role", "option"); + reactionTypeButton.setAttribute("aria-selected", "false"); reactionTypeButton.type = "button"; reactionTypeButton.tabIndex = 0; reactionTypeButton.className = "reactionTypeButton jsTooltip"; @@ -130,6 +136,7 @@ class ReactionPopover { popover.querySelectorAll(".reactionTypeButton.active").forEach((element: HTMLElement) => { element.classList.remove("active"); + element.setAttribute("aria-selected", "false"); }); if (parseInt(button.dataset.reactionTypeId!)) { @@ -137,6 +144,7 @@ class ReactionPopover { `.reactionTypeButton[data-reaction-type-id="${button.dataset.reactionTypeId}"]`, ) as HTMLButtonElement; reactionTypeButton.classList.add("active"); + reactionTypeButton.setAttribute("aria-selected", "true"); reactionTypeButton.hidden = false; } @@ -239,6 +247,8 @@ function setupPopoverButton(): void { wheneverFirstSeen("[data-reaction-object-type]", (button: HTMLButtonElement) => { let isOpen = false; + button.setAttribute("aria-haspopup", "listbox"); + button.setAttribute("aria-expanded", "false"); button.addEventListener("click", (event) => { event.stopPropagation(); // Necessary so that `Ui/CloseOverlay` does not close the popover immediately @@ -249,8 +259,11 @@ function setupPopoverButton(): void { } isOpen = true; + button.setAttribute("aria-expanded", "true"); + void reactionPopover.open(button).then(async (result: Result) => { isOpen = false; + button.setAttribute("aria-expanded", "false"); if (!result.ok) { return; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js index 91ecd25ef4..fe255b75d7 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js @@ -7,7 +7,7 @@ * @since 6.3 * @woltlabExcludeBundle tiny */ -define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertReaction", "WoltLabSuite/Core/Api/Reactions/SetReaction", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Dom/Change/Listener", "focus-trap", "WoltLabSuite/Core/Ui/Alignment", "WoltLabSuite/Core/Ui/Screen", "WoltLabSuite/Core/Ui/CloseOverlay", "WoltLabSuite/Core/Helper/PromiseMutex"], function (require, exports, tslib_1, RevertReaction_1, SetReaction_1, Selector_1, Listener_1, focus_trap_1, UiAlignment, UiScreen, CloseOverlay_1, PromiseMutex_1) { +define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertReaction", "WoltLabSuite/Core/Api/Reactions/SetReaction", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Dom/Change/Listener", "focus-trap", "WoltLabSuite/Core/Ui/Alignment", "WoltLabSuite/Core/Ui/Screen", "WoltLabSuite/Core/Ui/CloseOverlay", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Language"], function (require, exports, tslib_1, RevertReaction_1, SetReaction_1, Selector_1, Listener_1, focus_trap_1, UiAlignment, UiScreen, CloseOverlay_1, PromiseMutex_1, Language_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; @@ -49,10 +49,15 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe } this.#popover = document.createElement("div"); this.#popover.className = "reactionPopover forceHide"; + this.#popover.setAttribute("role", "listbox"); + this.#popover.setAttribute("aria-orientation", "horizontal"); + this.#popover.setAttribute("aria-label", (0, Language_1.getPhrase)("wcf.reactions.react")); const popoverContent = document.createElement("div"); popoverContent.className = "reactionPopoverContent"; this.#getSortedReactionTypes().forEach((reactionType) => { const reactionTypeButton = document.createElement("button"); + reactionTypeButton.setAttribute("role", "option"); + reactionTypeButton.setAttribute("aria-selected", "false"); reactionTypeButton.type = "button"; reactionTypeButton.tabIndex = 0; reactionTypeButton.className = "reactionTypeButton jsTooltip"; @@ -93,10 +98,12 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe } popover.querySelectorAll(".reactionTypeButton.active").forEach((element) => { element.classList.remove("active"); + element.setAttribute("aria-selected", "false"); }); if (parseInt(button.dataset.reactionTypeId)) { const reactionTypeButton = popover.querySelector(`.reactionTypeButton[data-reaction-type-id="${button.dataset.reactionTypeId}"]`); reactionTypeButton.classList.add("active"); + reactionTypeButton.setAttribute("aria-selected", "true"); reactionTypeButton.hidden = false; } popover.classList.remove("forceHide"); @@ -171,6 +178,8 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe const reactionPopover = new ReactionPopover(); (0, Selector_1.wheneverFirstSeen)("[data-reaction-object-type]", (button) => { let isOpen = false; + button.setAttribute("aria-haspopup", "listbox"); + button.setAttribute("aria-expanded", "false"); button.addEventListener("click", (event) => { event.stopPropagation(); // Necessary so that `Ui/CloseOverlay` does not close the popover immediately if (isOpen) { @@ -179,8 +188,10 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe return; } isOpen = true; + button.setAttribute("aria-expanded", "true"); void reactionPopover.open(button).then(async (result) => { isOpen = false; + button.setAttribute("aria-expanded", "false"); if (!result.ok) { return; } From fa8d75be3f28ebbc7b72263fc0e95b9f1301d0f7 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 13 Mar 2026 11:06:19 +0100 Subject: [PATCH 08/14] Use new reaction button on `ArticlePage` --- com.woltlab.wcf/templates/article.tpl | 30 ++----------------- .../files/lib/data/article/Article.class.php | 26 +++++++++++++++- .../data/article/ArticleCollection.class.php | 28 +++++++++++++++++ .../files/lib/page/ArticlePage.class.php | 17 +---------- 4 files changed, 57 insertions(+), 44 deletions(-) create mode 100644 wcfsetup/install/files/lib/data/article/ArticleCollection.class.php diff --git a/com.woltlab.wcf/templates/article.tpl b/com.woltlab.wcf/templates/article.tpl index 024dfb2937..0386299d2f 100644 --- a/com.woltlab.wcf/templates/article.tpl +++ b/com.woltlab.wcf/templates/article.tpl @@ -67,7 +67,6 @@
getReactionHandler()->getDataAttributes('com.woltlab.wcf.likeableArticle', $article->articleID)} > {if $articleContent->getImage() && $articleContent->getImage()->hasThumbnail('large')}