Convert `Ui/Reaction/CountButton` to TypeScript
authorAlexander Ebert <ebert@woltlab.com>
Mon, 2 Nov 2020 17:10:30 +0000 (18:10 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Mon, 2 Nov 2020 17:10:30 +0000 (18:10 +0100)
global.d.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/CountButtons.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Data.js [new file with mode: 0644]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.js [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.ts [new file with mode: 0644]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Data.ts [new file with mode: 0644]

index 20766bfff57a4d8ac1c2125675544fb4964fa093..23265d7316591fc4e5ef2d7ac8f8ec23aafcde38 100644 (file)
@@ -1,14 +1,18 @@
-import DatePicker from './wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker';
-import Devtools from './wcfsetup/install/files/ts/WoltLabSuite/Core/Devtools';
-import DomUtil from './wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util';
-import * as ColorUtil from './wcfsetup/install/files/ts/WoltLabSuite/Core/ColorUtil';
-import UiDropdownSimple from './wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Simple';
+import DatePicker from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker";
+import Devtools from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Devtools";
+import DomUtil from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util";
+import * as ColorUtil from "./wcfsetup/install/files/ts/WoltLabSuite/Core/ColorUtil";
+import UiDropdownSimple from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Simple";
 import "@woltlab/zxcvbn";
+import { Reaction } from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Data";
 
 declare global {
   interface Window {
     Devtools?: typeof Devtools;
     ENABLE_DEBUG_MODE: boolean;
+    REACTION_TYPES: {
+      [key: string]: Reaction;
+    };
     SECURITY_TOKEN: string;
     TIME_NOW: number;
     WCF_PATH: string;
index 359846bf4dbe6cbe3a6797ef07ae0aadb4e19995..9bd0fd6c4b9094bda805be09fccb240d29dbc47e 100644 (file)
 /**
  * Provides interface elements to use reactions.
  *
- * @author     Joshua Ruesweg
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Ui/Reaction/Handler
+ * @author  Joshua Ruesweg
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Ui/Reaction/Handler
  * @since       5.2
  */
-define([
-    'Ajax', 'Core', 'Dictionary', 'Language',
-    'ObjectMap', 'StringUtil', 'Dom/ChangeListener', 'Dom/Util',
-    'Ui/Dialog', 'EventHandler'
-], function (Ajax, Core, Dictionary, Language, ObjectMap, StringUtil, DomChangeListener, DomUtil, UiDialog, EventHandler) {
+define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Change/Listener", "../../Dom/Util", "../../Event/Handler", "../../StringUtil", "../Dialog"], function (require, exports, tslib_1, Ajax, Core, Listener_1, Util_1, EventHandler, StringUtil, Dialog_1) {
     "use strict";
-    /**
-     * @constructor
-     */
-    function CountButtons(objectType, options) { this.init(objectType, options); }
-    CountButtons.prototype = {
+    Ajax = tslib_1.__importStar(Ajax);
+    Core = tslib_1.__importStar(Core);
+    Listener_1 = tslib_1.__importDefault(Listener_1);
+    Util_1 = tslib_1.__importDefault(Util_1);
+    EventHandler = tslib_1.__importStar(EventHandler);
+    StringUtil = tslib_1.__importStar(StringUtil);
+    Dialog_1 = tslib_1.__importDefault(Dialog_1);
+    class CountButtons {
         /**
          * Initializes the like handler.
-         *
-         * @param      {string}        objectType      object type
-         * @param      {object}        options         initialization options
          */
-        init: function (objectType, options) {
-            if (options.containerSelector === '') {
+        constructor(objectType, opts) {
+            this._containers = new Map();
+            this._currentObjectId = 0;
+            this._objects = new Map();
+            if (!opts.containerSelector) {
                 throw new Error("[WoltLabSuite/Core/Ui/Reaction/CountButtons] Expected a non-empty string for option 'containerSelector'.");
             }
-            this._containers = new Dictionary();
-            this._objects = new Dictionary();
             this._objectType = objectType;
             this._options = Core.extend({
                 // selectors
-                summaryListSelector: '.reactionSummaryList',
-                containerSelector: '',
+                summaryListSelector: ".reactionSummaryList",
+                containerSelector: "",
                 isSingleItem: false,
                 // optional parameters
                 parameters: {
-                    data: {}
-                }
-            }, options);
-            this.initContainers(options, objectType);
-            DomChangeListener.add('WoltLabSuite/Core/Ui/Reaction/CountButtons-' + objectType, this.initContainers.bind(this));
-        },
+                    data: {},
+                },
+            }, opts);
+            this.initContainers();
+            Listener_1.default.add(`WoltLabSuite/Core/Ui/Reaction/CountButtons-${objectType}`, () => this.initContainers());
+        }
         /**
          * Initialises the containers.
          */
-        initContainers: function () {
-            var element, elements = elBySelAll(this._options.containerSelector), elementData, triggerChange = false, objectId;
-            for (var i = 0, length = elements.length; i < length; i++) {
-                element = elements[i];
-                if (this._containers.has(DomUtil.identify(element))) {
-                    continue;
+        initContainers() {
+            let triggerChange = false;
+            document.querySelectorAll(this._options.containerSelector).forEach((element) => {
+                const elementId = Util_1.default.identify(element);
+                if (this._containers.has(elementId)) {
+                    return;
                 }
-                objectId = ~~elData(element, 'object-id');
-                elementData = {
+                const objectId = ~~element.dataset.objectId;
+                const elementData = {
                     reactButton: null,
                     summary: null,
                     objectId: objectId,
-                    element: element
+                    element: element,
                 };
-                this._containers.set(DomUtil.identify(element), elementData);
+                this._containers.set(elementId, elementData);
                 this._initReactionCountButtons(element, elementData);
-                var objects = [];
-                if (this._objects.has(objectId)) {
-                    objects = this._objects.get(objectId);
-                }
+                const objects = this._objects.get(objectId) || [];
                 objects.push(elementData);
                 this._objects.set(objectId, objects);
                 triggerChange = true;
-            }
+            });
             if (triggerChange) {
-                DomChangeListener.trigger();
+                Listener_1.default.trigger();
             }
-        },
+        }
         /**
          * Update the count buttons with the given data.
-         *
-         * @param       {int}           objectId
-         * @param       {object}        data
          */
-        updateCountButtons: function (objectId, data) {
-            var triggerChange = false;
-            this._objects.get(objectId).forEach(function (elementData) {
-                var summaryList = elBySel(this._options.summaryListSelector, this._options.isSingleItem ? undefined : elementData.element);
+        updateCountButtons(objectId, data) {
+            let triggerChange = false;
+            this._objects.get(objectId).forEach((elementData) => {
+                let summaryList;
+                if (this._options.isSingleItem) {
+                    summaryList = document.querySelector(this._options.summaryListSelector);
+                }
+                else {
+                    summaryList = elementData.element.querySelector(this._options.summaryListSelector);
+                }
                 // summary list for the object not found; abort
-                if (summaryList === null)
+                if (summaryList === null) {
                     return;
-                var sortedElements = {}, elements = elBySelAll('.reactCountButton', summaryList);
-                for (var i = 0, length = elements.length; i < length; i++) {
-                    var reactionTypeId = elData(elements[i], 'reaction-type-id');
-                    if (data.hasOwnProperty(reactionTypeId)) {
-                        sortedElements[reactionTypeId] = elements[i];
+                }
+                const existingReactions = new Map(Object.entries(data));
+                const sortedElements = new Map();
+                summaryList.querySelectorAll(".reactCountButton").forEach((reaction) => {
+                    const reactionTypeId = reaction.dataset.reactionTypeId;
+                    if (existingReactions.has(reactionTypeId)) {
+                        sortedElements.set(reactionTypeId, reaction);
                     }
                     else {
                         // The reaction no longer has any reactions.
-                        elRemove(elements[i]);
+                        reaction.remove();
                     }
-                }
-                Object.keys(data).forEach(function (key) {
-                    if (sortedElements[key] !== undefined) {
-                        var reactionCount = elBySel('.reactionCount', sortedElements[key]);
-                        reactionCount.innerHTML = StringUtil.shortUnit(data[key]);
+                });
+                const availableReactions = new Map(Object.entries(window.REACTION_TYPES));
+                existingReactions.forEach((count, reactionTypeId) => {
+                    if (sortedElements.has(reactionTypeId)) {
+                        const reaction = sortedElements.get(reactionTypeId);
+                        const reactionCount = reaction.querySelector(".reactionCount");
+                        reactionCount.innerHTML = StringUtil.shortUnit(count);
                     }
-                    else if (REACTION_TYPES[key] !== undefined) {
-                        var createdElement = elCreate('span');
-                        createdElement.className = 'reactCountButton';
-                        createdElement.innerHTML = REACTION_TYPES[key].renderedIcon;
-                        elData(createdElement, 'reaction-type-id', key);
-                        var countSpan = elCreate('span');
-                        countSpan.className = 'reactionCount';
-                        countSpan.innerHTML = StringUtil.shortUnit(data[key]);
+                    else if (availableReactions.has(reactionTypeId)) {
+                        const createdElement = document.createElement("span");
+                        createdElement.className = "reactCountButton";
+                        createdElement.innerHTML = availableReactions.get(reactionTypeId).renderedIcon;
+                        createdElement.dataset.reactionTypeId = reactionTypeId;
+                        const countSpan = document.createElement("span");
+                        countSpan.className = "reactionCount";
+                        countSpan.innerHTML = StringUtil.shortUnit(count);
                         createdElement.appendChild(countSpan);
                         summaryList.appendChild(createdElement);
                         triggerChange = true;
                     }
-                }, this);
-                window[(summaryList.childElementCount > 0 ? 'elShow' : 'elHide')](summaryList);
-            }.bind(this));
+                });
+                if (summaryList.childElementCount > 0) {
+                    Util_1.default.show(summaryList);
+                }
+                else {
+                    Util_1.default.hide(summaryList);
+                }
+            });
             if (triggerChange) {
-                DomChangeListener.trigger();
+                Listener_1.default.trigger();
             }
-        },
+        }
         /**
          * Initialized the reaction count buttons.
-         *
-         * @param       {element}        element
-         * @param       {object}        elementData
          */
-        _initReactionCountButtons: function (element, elementData) {
-            var summaryList = elBySel(this._options.summaryListSelector, this._options.isSingleItem ? undefined : element);
+        _initReactionCountButtons(element, elementData) {
+            let summaryList;
+            if (this._options.isSingleItem) {
+                summaryList = document.querySelector(this._options.summaryListSelector);
+            }
+            else {
+                summaryList = element.querySelector(this._options.summaryListSelector);
+            }
             if (summaryList !== null) {
-                summaryList.addEventListener('click', this._showReactionOverlay.bind(this, elementData.objectId));
+                summaryList.addEventListener("click", (ev) => this._showReactionOverlay(elementData.objectId, ev));
             }
-        },
+        }
         /**
          * Shows the reaction overly for a specific object.
-         *
-         * @param {int} objectId
-         * @param {Event} event
          */
-        _showReactionOverlay: function (objectId, event) {
+        _showReactionOverlay(objectId, event) {
             event.preventDefault();
             this._currentObjectId = objectId;
             this._showOverlay();
-        },
+        }
         /**
          * Shows a specific page of the current opened reaction overlay.
          */
-        _showOverlay: function () {
-            this._options.parameters.data.containerID = this._objectType + '-' + this._currentObjectId;
+        _showOverlay() {
+            this._options.parameters.data.containerID = `${this._objectType}-${this._currentObjectId}`;
             this._options.parameters.data.objectID = this._currentObjectId;
             this._options.parameters.data.objectType = this._objectType;
             Ajax.api(this, {
-                parameters: this._options.parameters
+                parameters: this._options.parameters,
             });
-        },
-        _ajaxSuccess: function (data) {
-            EventHandler.fire('com.woltlab.wcf.ReactionCountButtons', 'openDialog', data);
-            UiDialog.open(this, data.returnValues.template);
-            UiDialog.setTitle('userReactionOverlay-' + this._objectType, data.returnValues.title);
-        },
-        _ajaxSetup: function () {
+        }
+        _ajaxSuccess(data) {
+            EventHandler.fire("com.woltlab.wcf.ReactionCountButtons", "openDialog", data);
+            Dialog_1.default.open(this, data.returnValues.template);
+            Dialog_1.default.setTitle("userReactionOverlay-" + this._objectType, data.returnValues.title);
+        }
+        _ajaxSetup() {
             return {
                 data: {
-                    actionName: 'getReactionDetails',
-                    className: '\\wcf\\data\\reaction\\ReactionAction'
-                }
+                    actionName: "getReactionDetails",
+                    className: "\\wcf\\data\\reaction\\ReactionAction",
+                },
             };
-        },
-        _dialogSetup: function () {
+        }
+        _dialogSetup() {
             return {
-                id: 'userReactionOverlay-' + this._objectType,
+                id: `userReactionOverlay-${this._objectType}`,
                 options: {
-                    title: ""
+                    title: "",
                 },
-                source: null
+                source: null,
             };
         }
-    };
+    }
+    Core.enableLegacyInheritance(CountButtons);
     return CountButtons;
 });
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Data.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Data.js
new file mode 100644 (file)
index 0000000..2ae92b6
--- /dev/null
@@ -0,0 +1,4 @@
+define(["require", "exports"], function (require, exports) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.js
deleted file mode 100644 (file)
index 9f03754..0000000
+++ /dev/null
@@ -1,224 +0,0 @@
-/**
- * Provides interface elements to use reactions.
- *
- * @author     Joshua Ruesweg
- * @copyright  2001-2019 WoltLab GmbH
- * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module     WoltLabSuite/Core/Ui/Reaction/Handler
- * @since       5.2
- */
-define(
-       [
-               'Ajax',      'Core',          'Dictionary',         'Language',
-               'ObjectMap', 'StringUtil',    'Dom/ChangeListener', 'Dom/Util',
-               'Ui/Dialog', 'EventHandler'
-       ],
-       function(
-               Ajax,        Core,                        Dictionary,           Language,
-               ObjectMap,   StringUtil,                  DomChangeListener,    DomUtil,
-               UiDialog, EventHandler
-       )
-       {
-               "use strict";
-               
-               /**
-                * @constructor
-                */
-               function CountButtons(objectType, options) { this.init(objectType, options); }
-               CountButtons.prototype = {
-                       /**
-                        * Initializes the like handler.
-                        *
-                        * @param       {string}        objectType      object type
-                        * @param       {object}        options         initialization options
-                        */
-                       init: function(objectType, options) {
-                               if (options.containerSelector === '') {
-                                       throw new Error("[WoltLabSuite/Core/Ui/Reaction/CountButtons] Expected a non-empty string for option 'containerSelector'.");
-                               }
-                               
-                               this._containers = new Dictionary();
-                               this._objects = new Dictionary();
-                               this._objectType = objectType;
-                               
-                               this._options = Core.extend({
-                                       // selectors
-                                       summaryListSelector: '.reactionSummaryList',
-                                       containerSelector: '',
-                                       isSingleItem: false,
-                                       
-                                       // optional parameters
-                                       parameters: {
-                                               data: {}
-                                       }
-                               }, options);
-                               
-                               this.initContainers(options, objectType);
-                               
-                               DomChangeListener.add('WoltLabSuite/Core/Ui/Reaction/CountButtons-' + objectType, this.initContainers.bind(this));
-                       },
-                       
-                       /**
-                        * Initialises the containers. 
-                        */
-                       initContainers: function() {
-                               var element, elements = elBySelAll(this._options.containerSelector), elementData, triggerChange = false, objectId;
-                               for (var i = 0, length = elements.length; i < length; i++) {
-                                       element = elements[i];
-                                       if (this._containers.has(DomUtil.identify(element))) {
-                                               continue;
-                                       }
-                                       
-                                       objectId = ~~elData(element, 'object-id');
-                                       elementData = {
-                                               reactButton: null,
-                                               summary: null,
-                                               
-                                               objectId: objectId, 
-                                               element: element
-                                       };
-                                       
-                                       this._containers.set(DomUtil.identify(element), elementData);
-                                       this._initReactionCountButtons(element, elementData);
-
-                                       var objects = [];
-                                       if (this._objects.has(objectId)) {
-                                               objects = this._objects.get(objectId);
-                                       }
-                                       
-                                       objects.push(elementData);
-                                       
-                                       this._objects.set(objectId, objects);
-                                       
-                                       triggerChange = true;
-                               }
-                               
-                               if (triggerChange) {
-                                       DomChangeListener.trigger();
-                               }
-                       },
-                       
-                       /**
-                        * Update the count buttons with the given data. 
-                        * 
-                        * @param       {int}           objectId
-                        * @param       {object}        data
-                        */
-                       updateCountButtons: function(objectId, data) {
-                               var triggerChange = false;
-                               this._objects.get(objectId).forEach(function(elementData) {
-                                       var summaryList = elBySel(this._options.summaryListSelector, this._options.isSingleItem ? undefined : elementData.element);
-                                       
-                                       // summary list for the object not found; abort
-                                       if (summaryList === null) return; 
-                                       
-                                       var sortedElements = {}, elements = elBySelAll('.reactCountButton', summaryList);
-                                       for (var i = 0, length = elements.length; i < length; i++) {
-                                               var reactionTypeId = elData(elements[i], 'reaction-type-id');
-                                               if (data.hasOwnProperty(reactionTypeId)) {
-                                                       sortedElements[reactionTypeId] = elements[i];
-                                               }
-                                               else {
-                                                       // The reaction no longer has any reactions.
-                                                       elRemove(elements[i]);
-                                               }
-                                       }
-                                       
-                                       Object.keys(data).forEach(function(key) {
-                                               if (sortedElements[key] !== undefined) {
-                                                       var reactionCount = elBySel('.reactionCount', sortedElements[key]);
-                                                       reactionCount.innerHTML = StringUtil.shortUnit(data[key]);
-                                               }
-                                               else if (REACTION_TYPES[key] !== undefined) {
-                                                       var createdElement = elCreate('span');
-                                                       createdElement.className = 'reactCountButton';
-                                                       createdElement.innerHTML = REACTION_TYPES[key].renderedIcon;
-                                                       elData(createdElement, 'reaction-type-id', key);
-
-                                                       var countSpan = elCreate('span');
-                                                       countSpan.className = 'reactionCount';
-                                                       countSpan.innerHTML = StringUtil.shortUnit(data[key]);
-                                                       createdElement.appendChild(countSpan);
-                                                       
-                                                       summaryList.appendChild(createdElement);
-                                                       
-                                                       triggerChange = true;
-                                               }
-                                       }, this);
-                                       
-                                       window[(summaryList.childElementCount > 0 ? 'elShow' : 'elHide')](summaryList);
-                               }.bind(this));
-                               
-                               if (triggerChange) {
-                                       DomChangeListener.trigger();
-                               }
-                       },
-                       
-                       /**
-                        * Initialized the reaction count buttons. 
-                        * 
-                        * @param       {element}        element
-                        * @param       {object}        elementData
-                        */
-                       _initReactionCountButtons: function(element, elementData) {
-                               var summaryList = elBySel(this._options.summaryListSelector, this._options.isSingleItem ? undefined : element);
-                               if (summaryList !== null) {
-                                       summaryList.addEventListener('click', this._showReactionOverlay.bind(this, elementData.objectId));
-                               }
-                       },
-                       
-                       /**
-                        * Shows the reaction overly for a specific object. 
-                        *
-                        * @param {int} objectId
-                        * @param {Event} event
-                        */
-                       _showReactionOverlay: function(objectId, event) {
-                               event.preventDefault();
-                               
-                               this._currentObjectId = objectId;
-                               this._showOverlay();
-                       },
-                       
-                       /**
-                        * Shows a specific page of the current opened reaction overlay.
-                        */
-                       _showOverlay: function() {
-                               this._options.parameters.data.containerID = this._objectType + '-' + this._currentObjectId;
-                               this._options.parameters.data.objectID = this._currentObjectId;
-                               this._options.parameters.data.objectType = this._objectType;
-                               
-                               Ajax.api(this, {
-                                       parameters: this._options.parameters
-                               });
-                       },
-                       
-                       _ajaxSuccess: function(data) {
-                               EventHandler.fire('com.woltlab.wcf.ReactionCountButtons', 'openDialog', data);
-                               
-                               UiDialog.open(this, data.returnValues.template);
-                               UiDialog.setTitle('userReactionOverlay-' + this._objectType, data.returnValues.title);
-                       },
-                       
-                       _ajaxSetup: function() {
-                               return {
-                                       data: {
-                                               actionName: 'getReactionDetails',
-                                               className: '\\wcf\\data\\reaction\\ReactionAction'
-                                       }
-                               };
-                       },
-                       
-                       _dialogSetup: function() {
-                               return {
-                                       id: 'userReactionOverlay-' + this._objectType,
-                                       options: {
-                                               title: ""
-                                       },
-                                       source: null
-                               };
-                       }
-               };
-               
-               return CountButtons;
-       });
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.ts
new file mode 100644 (file)
index 0000000..f02a96b
--- /dev/null
@@ -0,0 +1,261 @@
+/**
+ * Provides interface elements to use reactions.
+ *
+ * @author  Joshua Ruesweg
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module  WoltLabSuite/Core/Ui/Reaction/Handler
+ * @since       5.2
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import { DialogCallbackSetup } from "../Dialog/Data";
+import DomChangeListener from "../../Dom/Change/Listener";
+import DomUtil from "../../Dom/Util";
+import * as EventHandler from "../../Event/Handler";
+import { Reaction, ReactionStats } from "./Data";
+import * as StringUtil from "../../StringUtil";
+import UiDialog from "../Dialog";
+
+interface CountButtonsOptions {
+  // selectors
+  summaryListSelector: string;
+  containerSelector: string;
+  isSingleItem: boolean;
+
+  // optional parameters
+  parameters: {
+    data: {
+      [key: string]: unknown;
+    };
+  };
+}
+
+interface ElementData {
+  element: HTMLElement;
+  objectId: number;
+  reactButton: null;
+  summary: null;
+}
+
+interface AjaxResponse extends ResponseData {
+  returnValues: {
+    template: string;
+    title: string;
+  };
+}
+
+class CountButtons {
+  protected readonly _containers = new Map<string, ElementData>();
+  protected _currentObjectId = 0;
+  protected readonly _objects = new Map<number, ElementData[]>();
+  protected readonly _objectType: string;
+  protected readonly _options: CountButtonsOptions;
+
+  /**
+   * Initializes the like handler.
+   */
+  constructor(objectType: string, opts: Partial<CountButtonsOptions>) {
+    if (!opts.containerSelector) {
+      throw new Error(
+        "[WoltLabSuite/Core/Ui/Reaction/CountButtons] Expected a non-empty string for option 'containerSelector'.",
+      );
+    }
+
+    this._objectType = objectType;
+
+    this._options = Core.extend(
+      {
+        // selectors
+        summaryListSelector: ".reactionSummaryList",
+        containerSelector: "",
+        isSingleItem: false,
+
+        // optional parameters
+        parameters: {
+          data: {},
+        },
+      },
+      opts,
+    ) as CountButtonsOptions;
+
+    this.initContainers();
+
+    DomChangeListener.add(`WoltLabSuite/Core/Ui/Reaction/CountButtons-${objectType}`, () => this.initContainers());
+  }
+
+  /**
+   * Initialises the containers.
+   */
+  initContainers(): void {
+    let triggerChange = false;
+    document.querySelectorAll(this._options.containerSelector).forEach((element: HTMLElement) => {
+      const elementId = DomUtil.identify(element);
+      if (this._containers.has(elementId)) {
+        return;
+      }
+
+      const objectId = ~~element.dataset.objectId!;
+      const elementData: ElementData = {
+        reactButton: null,
+        summary: null,
+
+        objectId: objectId,
+        element: element,
+      };
+
+      this._containers.set(elementId, elementData);
+      this._initReactionCountButtons(element, elementData);
+
+      const objects = this._objects.get(objectId) || [];
+
+      objects.push(elementData);
+
+      this._objects.set(objectId, objects);
+
+      triggerChange = true;
+    });
+
+    if (triggerChange) {
+      DomChangeListener.trigger();
+    }
+  }
+
+  /**
+   * Update the count buttons with the given data.
+   */
+  updateCountButtons(objectId: number, data: ReactionStats): void {
+    let triggerChange = false;
+    this._objects.get(objectId)!.forEach((elementData) => {
+      let summaryList: HTMLElement | null;
+      if (this._options.isSingleItem) {
+        summaryList = document.querySelector(this._options.summaryListSelector);
+      } else {
+        summaryList = elementData.element.querySelector(this._options.summaryListSelector);
+      }
+
+      // summary list for the object not found; abort
+      if (summaryList === null) {
+        return;
+      }
+
+      const existingReactions = new Map<string, number>(Object.entries(data));
+
+      const sortedElements = new Map<string, HTMLElement>();
+      summaryList.querySelectorAll(".reactCountButton").forEach((reaction: HTMLElement) => {
+        const reactionTypeId = reaction.dataset.reactionTypeId!;
+        if (existingReactions.has(reactionTypeId)) {
+          sortedElements.set(reactionTypeId, reaction);
+        } else {
+          // The reaction no longer has any reactions.
+          reaction.remove();
+        }
+      });
+
+      const availableReactions = new Map<string, Reaction>(Object.entries(window.REACTION_TYPES));
+
+      existingReactions.forEach((count, reactionTypeId) => {
+        if (sortedElements.has(reactionTypeId)) {
+          const reaction = sortedElements.get(reactionTypeId)!;
+          const reactionCount = reaction.querySelector(".reactionCount") as HTMLElement;
+          reactionCount.innerHTML = StringUtil.shortUnit(count);
+        } else if (availableReactions.has(reactionTypeId)) {
+          const createdElement = document.createElement("span");
+          createdElement.className = "reactCountButton";
+          createdElement.innerHTML = availableReactions.get(reactionTypeId)!.renderedIcon;
+          createdElement.dataset.reactionTypeId = reactionTypeId;
+
+          const countSpan = document.createElement("span");
+          countSpan.className = "reactionCount";
+          countSpan.innerHTML = StringUtil.shortUnit(count);
+          createdElement.appendChild(countSpan);
+
+          summaryList!.appendChild(createdElement);
+
+          triggerChange = true;
+        }
+      });
+
+      if (summaryList.childElementCount > 0) {
+        DomUtil.show(summaryList);
+      } else {
+        DomUtil.hide(summaryList);
+      }
+    });
+
+    if (triggerChange) {
+      DomChangeListener.trigger();
+    }
+  }
+
+  /**
+   * Initialized the reaction count buttons.
+   */
+  protected _initReactionCountButtons(element: HTMLElement, elementData: ElementData): void {
+    let summaryList: HTMLElement | null;
+    if (this._options.isSingleItem) {
+      summaryList = document.querySelector(this._options.summaryListSelector);
+    } else {
+      summaryList = element.querySelector(this._options.summaryListSelector);
+    }
+
+    if (summaryList !== null) {
+      summaryList.addEventListener("click", (ev) => this._showReactionOverlay(elementData.objectId, ev));
+    }
+  }
+
+  /**
+   * Shows the reaction overly for a specific object.
+   */
+  protected _showReactionOverlay(objectId: number, event: MouseEvent): void {
+    event.preventDefault();
+
+    this._currentObjectId = objectId;
+    this._showOverlay();
+  }
+
+  /**
+   * Shows a specific page of the current opened reaction overlay.
+   */
+  protected _showOverlay(): void {
+    this._options.parameters.data.containerID = `${this._objectType}-${this._currentObjectId}`;
+    this._options.parameters.data.objectID = this._currentObjectId;
+    this._options.parameters.data.objectType = this._objectType;
+
+    Ajax.api(this, {
+      parameters: this._options.parameters,
+    });
+  }
+
+  _ajaxSuccess(data: AjaxResponse): void {
+    EventHandler.fire("com.woltlab.wcf.ReactionCountButtons", "openDialog", data);
+
+    UiDialog.open(this, data.returnValues.template);
+    UiDialog.setTitle("userReactionOverlay-" + this._objectType, data.returnValues.title);
+  }
+
+  _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+    return {
+      data: {
+        actionName: "getReactionDetails",
+        className: "\\wcf\\data\\reaction\\ReactionAction",
+      },
+    };
+  }
+
+  _dialogSetup(): ReturnType<DialogCallbackSetup> {
+    return {
+      id: `userReactionOverlay-${this._objectType}`,
+      options: {
+        title: "",
+      },
+      source: null,
+    };
+  }
+}
+
+Core.enableLegacyInheritance(CountButtons);
+
+export = CountButtons;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Data.ts
new file mode 100644 (file)
index 0000000..f27516c
--- /dev/null
@@ -0,0 +1,12 @@
+export interface Reaction {
+  title: string;
+  renderedIcon: string;
+  iconPath: string;
+  showOrder: number;
+  reactionTypeID: number;
+  isAssignable: 1 | 0;
+}
+
+export interface ReactionStats {
+  [key: string]: number;
+}