Explorar el Código

Reactions modes: Solo / Few / Many, default reaction, per-person limit, split counters (UNA + NEO) (#5834)

* Reactions settings: mode, default reaction, reactions per person, split counters

Replaces the "quick mode" checkbox with four Site settings options:
- Reactions mode: solo (only the default reaction), few (the reactions list),
  many (the list plus any emoji).
- Default reaction: from the reactions list, or none.
- Reactions per person: 1 to 5 different reactions per object (few/many).
- Split counters: the API counter is one item per reaction.

Backend:
- vote() validates the reaction against the mode, uses the object's value,
  lets a person hold up to the limit (a new one replaces the old when the
  limit is 1) and takes back a reaction given again.
- Free-picked emoji are stored as e_<hex code points>: the tables' unicode
  collation treats emoji above U+FFFF as equal, so raw emoji would collide in
  the (object_id, reaction) key.
- Counters, trigger totals and "voted by" include free-picked emoji.
- The API element carries options (mode, default, per_user, split_counters,
  the list with emoji) and the viewer's reactions.
- BxDolVote::vote() is split into checks and _vote() (no behaviour change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bb7bc27c7cdffa88b12761b4988ae30cfbab03f0)

* Neo: rename reactions to TypeScript (no content change)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 3a16bb2682c370e246e1735583aab0a90698bf1c)

* Neo: reactions modes (solo / few / many), default reaction, per-person limit, split counters

Reads UNA's reactions settings from the element's `options`; a backend
without them keeps today's behaviour (few, "like" by default, one reaction).

- Solo: the button is the default reaction and toggles it.
- Few / Many with a default reaction: a click gives the default (or takes
  back the reaction the button shows); hover (web) or long press (native)
  opens the choice. Without a default the button is a smile-plus that
  always opens the choice.
- Few shows the reactions bar; Many shows a new emoji picker (search, group
  tabs, the site's reactions first). Free-picked emoji are sent as e_<hex>
  codes; an emoji on the list is sent as that reaction.
- Up to N reactions per person: the choice marks your reactions (tap to
  take one back) and dims the rest at the limit.
- Split counters: a pill per reaction, yours highlighted. Counters include
  free-picked emoji, also from live socket updates.
- Optimistic updates roll back when the server refuses.
- Emoji data: scripts/generate-emoji-data.js writes lib/emoji/emoji-data.ts
  (1,870 emoji up to Emoji 15.0, 68 KB) from the unicode-emoji-json
  devDependency; the picker is lazy-loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit ad29fda97f9f98f6fa2d3994d167f11cb233f294)

* Neo: name reaction counters for screen readers

A pill's label is its count, so NeoButton fell back to the icon's name and
the Love pill read "Like" (Heart). Pills read "<reaction> <count>", the
compound counter "Reactions <total>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 09b9096fcf194647285e74a6d3d681889ba65015)

* Reactions: take back a reaction the settings no longer offer

An emoji picked in Many mode, or a reaction Solo mode leaves out, could not be
taken back after the settings changed: vote() checked the reaction against the
current list first. One's own reaction is now always accepted for undo, and the
whole Reactions list stays available for lookups in Solo mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit ed52d8ddff39246f2dc298fad5b2a9b0bd25f543)

* Neo: reactions on native - picker placement, VoiceOver, live counters

- The popover backdrop is a sibling of the panel, so iOS exposes each reaction
  as its own button (a wrapping touchable merged them into one element).
- The panel is placed relative to the measured overlay instead of a fixed
  -20pt offset, and anchored by its bottom edge when it opens above the button.
- A vote updates the counter at once: the action and the counter are separate
  instances and only synced through the socket.
- A count UNA leaves out after a vote is 0, not the stale count from the page.
- Picked emoji are named by the emoji for screen readers ("fox 1").
- Emoji picker: one tap picks while the search keyboard is open, no
  autocapitalize/autocorrect in search, tabs stay while searching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d6ed6a5340194d5f218728f38eab4a58e0c6d887)

* Reactions settings in their own section: Studio > Settings > Global > Reactions

The four options (mode, default reaction, reactions per person, split
counters) move out of General into a new "reactions" category, shown as
the last section of the Global settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit de925839e9f62f63e0c4e0f6d3a8214268ac840e)

* Neo: the reaction bar and emoji picker say which reactions are chosen on web

React Native Web drops accessibilityState on these touchables, so on web
a screen reader heard neither the chosen reactions nor the ones disabled
at the per-person limit. They now carry aria-pressed / aria-disabled;
native keeps accessibilityState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 09ae6a9d29c9d61d4cd32e7ff95b9271bd47bf5c)

* Reactions: classic web can take back a reaction the mode no longer offers

actionVote parsed the request with normalizeReaction only, so a leftover
reaction (an emoji picked before Many mode was turned off, a reaction Solo
mode leaves out) was refused before vote() could take it back. The request
now also accepts one of the author's own reactions, by name, code or emoji.

The integration tests set the test member's reactions aside and give them
back in tearDown, so a run leaves the shared database as it found it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: picker offers only emoji UNA stores; a failed reaction is undone

The emoji data leaves out the 18 emoji UNA refuses as reactions (families,
couples with a heart or kiss, the England, Scotland and Wales flags): their
e_<hex> codes are longer than the reaction column or tag sequences, which
BxDolVoteReactions::isEmoji does not take.

doReaction() shows a reaction before the request finishes. A timeout or a
network error rejects without calling back, so the optimistic change stays;
it is now undone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: choose a reaction from the keyboard on web

A keyboard user could reach the reaction button and give the default
reaction, but had no way to open the bar or the emoji picker. Now the
arrow keys on the button open it (Enter and Space keep giving the default;
without a default they open it too) and focus goes to the reaction already
given, else the first one. The arrow keys move between reactions, Enter or
Space picks one, Escape closes, and focus returns to the button. The bar's
items are round, so the focus outline follows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: keyboard focus reaches the lazy emoji picker; Escape closes any reactions panel

The emoji picker loads lazily, so on its first opening from the keyboard the
panel had no controls yet; focus now waits for them to appear. Escape was
handled only inside the panel, so a picker opened by hover or click (focus
outside it) ignored it; it now closes the panel however it was opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: reactions panel focus passes the strict native typecheck

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: a failed reaction only undoes itself, not a newer change

While a vote request waits, another tap, a vote from the counter or the
socket can change the reactions. The undo after a failed request put back
the state from before the tap, dropping those. Every change is now counted,
and the undo only runs when nothing changed since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reactions: split counters apply to the web site's counters too

The Split counters setting only reached the API, so NEO showed a counter
per reaction or one total, while the web site kept whatever style each page
asked for. The web counter now follows the setting the same way; a page
that asks for the simple counter (a bare number) keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: other members' votes don't stop a failed reaction from being undone

Every socket update counted as a newer change, including another member's
vote, which only brings new counts. A failed request then kept showing the
reaction that was never saved. Only changes to the viewer's own reactions
now block the undo, and the undo keeps counts the server sent meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko hace 1 semana
padre
commit
3913667567

+ 12 - 2
inc/classes/BxDolVote.php

@@ -204,7 +204,6 @@ class BxDolVote extends BxDolObjectVote
     public function vote($aVoteData = [], $aRequestParamsData = [])
     {
         $iObjectId = $this->getId();
-        $iObjectAuthorId = $this->getObjectAuthorId($iObjectId);
         $iAuthorId = $this->_getAuthorId();
         $iAuthorIp = $this->_getAuthorIp();
 
@@ -224,6 +223,17 @@ class BxDolVote extends BxDolObjectVote
                 $aVoteData = array_intersect_key($aTrack, $aVoteData);
         }
 
+        return $this->_vote($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, $bPerformUndo, $aRequestParamsData);
+    }
+
+    /**
+     * Store a vote (or take it back when $bPerformUndo), fire the alerts and the socket event.
+     * Checks (permissions, duplicates) are the caller's job.
+     */
+    protected function _vote($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, $bPerformUndo, $aRequestParamsData = [])
+    {
+        $iObjectAuthorId = $this->getObjectAuthorId($iObjectId);
+
         $iId = $this->_putVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, $bPerformUndo);
         if($iId === false)
             return ['code' => BX_DOL_OBJECT_ERR_CANNOT_PERFORM];
@@ -273,7 +283,7 @@ class BxDolVote extends BxDolObjectVote
             'object_id' => $iObjectId
         ], $aAlertExtras, $aVoteData));
 
-        $aResult = $this->_returnVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, !$bVoted, $aRequestParamsData);
+        $aResult = $this->_returnVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, !$bPerformUndo, $aRequestParamsData);
 
         if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled())
             $oSockets->sendEvent($this->getSocketName(), $iObjectId, 'voted', json_encode($this->_returnVoteDataForSocket($aResult)));

+ 359 - 54
inc/classes/BxDolVoteReactions.php

@@ -16,15 +16,30 @@ class BxDolVoteReactions extends BxTemplVote
     protected static $_sCounterStyleDivided = 'divided'; // counters [icon + counter] divided by reactions.
     protected static $_sCounterStyleCompound = 'compound'; // total counter with a list of reaction icons.
 
+    public static $sModeSolo = 'solo'; // one reaction (the default one), cast with a click.
+    public static $sModeFew = 'few'; // the reactions list.
+    public static $sModeMany = 'many'; // the reactions list plus any emoji.
+
+    public static $iPerUserMax = 5; // most reactions one person may give to one object.
+
+    protected static $_sEmojiCodePrefix = 'e_'; // free-picked emoji are stored as e_<hex code points>.
+    protected static $_iReactionLengthMax = 32; // the `reaction` column is varchar(32).
+
     protected $_sMenuDoVote; //--- Do vote (reaction)  menu name.
 
     protected $_sDataList; //--- Reactions list name.
     protected $_aDataList; //--- Reactions list with all reactions.
+    protected $_aDataListAll; //--- The whole Reactions list, also the reactions Solo mode leaves out.
 
     protected $_sDefault; //--- Default reaction name.
 
-    protected $_bQuickMode; //--- Give 'default' reaction when clicked.
-    protected $_bSingleMode; //--- Auto enabled when only one item in Reactions list.
+    protected $_bQuickMode; //--- Give 'default' reaction when clicked (a default reaction is set).
+    protected $_bSingleMode; //--- Only one reaction is available (Solo mode, or one item in Reactions list).
+
+    protected $_sMode; //--- Reactions mode: solo, few or many.
+    protected $_sDefaultReaction; //--- Default reaction name from settings, '' when there is none.
+    protected $_iPerUser; //--- How many different reactions one person may give to one object.
+    protected $_bSplitCounters; //--- Show a counter per reaction instead of one total.
 
     public function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
     {
@@ -37,10 +52,19 @@ class BxDolVoteReactions extends BxTemplVote
 
         $this->_sDataList = 'sys_vote_reactions';
         $this->_aDataList = [];
+        $this->_aDataListAll = [];
 
         $this->_sDefault = 'default';
 
-        $this->_bQuickMode = getParam('sys_vote_reactions_quick_mode') == 'on';
+        $this->_sMode = getParam('sys_vote_reactions_mode');
+        if(!in_array($this->_sMode, [self::$sModeSolo, self::$sModeFew, self::$sModeMany]))
+            $this->_sMode = self::$sModeFew;
+
+        $this->_sDefaultReaction = trim((string)getParam('sys_vote_reactions_default'));
+
+        $this->_iPerUser = $this->_sMode == self::$sModeSolo ? 1 : max(1, min(self::$iPerUserMax, (int)getParam('sys_vote_reactions_per_user')));
+
+        $this->_bSplitCounters = getParam('sys_vote_reactions_split_counters') == 'on';
     }
 
     public function init($iId)
@@ -50,12 +74,12 @@ class BxDolVoteReactions extends BxTemplVote
 
         $aReactions = BxDolFormQuery::getDataItems($this->_sDataList, false, BX_DATA_VALUES_ALL);
 
-        $sDefault = '';
+        $sPlaceholder = ''; //--- The reaction which carries the 'not voted yet' look in its data.
         foreach($aReactions as $sReaction => $aReaction) {
             $aData = !empty($aReaction['Data']) ? unserialize($aReaction['Data']) : array();
 
             if(!empty($aData['default']))
-                $sDefault = $sReaction;
+                $sPlaceholder = $sReaction;
 
             $this->_aDataList[$sReaction] = array(
                 'name' => $sReaction,
@@ -71,14 +95,32 @@ class BxDolVoteReactions extends BxTemplVote
             );
         }
 
+        //--- The default reaction must be on the list.
+        if($this->_sDefaultReaction !== '' && !isset($this->_aDataList[$this->_sDefaultReaction]))
+            $this->_sDefaultReaction = '';
+
+        $this->_aDataListAll = $this->_aDataList;
+
+        //--- Solo: only the default reaction (or the first one on the list) is available.
+        if($this->_sMode == self::$sModeSolo && !empty($this->_aDataList)) {
+            if($this->_sDefaultReaction === '')
+                $this->_sDefaultReaction = (string)key($this->_aDataList);
+
+            $this->_aDataList = [$this->_sDefaultReaction => $this->_aDataList[$this->_sDefaultReaction]];
+        }
+
         $this->_bSingleMode = count($this->_aDataList) == 1;
+        $this->_bQuickMode = $this->_sDefaultReaction !== '';
 
-        if(empty($sDefault) && !empty($this->_aDataList))
-            $sDefault = current(array_keys($this->_aDataList));
+        //--- The 'default' item is what the do-button shows before a vote: the default
+        //--- reaction, or the 'not voted yet' look (e.g. 'React' with a smile) when there is none.
+        $sBase = $this->_sDefaultReaction;
+        if($sBase === '')
+            $sBase = $sPlaceholder !== '' && isset($this->_aDataList[$sPlaceholder]) ? $sPlaceholder : (string)key($this->_aDataList);
 
-        if(!empty($sDefault)) {
-            $aDefault = $this->_aDataList[$sDefault];
-            if(((!$this->_bQuickMode && !$this->_bSingleMode) || $this->_bIsApi) && isset($aDefault['default'])) {
+        if($sBase !== '' && isset($this->_aDataList[$sBase])) {
+            $aDefault = $this->_aDataList[$sBase];
+            if(!$this->_bQuickMode && !$this->_bSingleMode && !empty($aDefault['default'])) {
                 if(is_array($aDefault['default']))
                     $aDefault = array_merge ($aDefault, $aDefault['default']);
                 else
@@ -101,12 +143,63 @@ class BxDolVoteReactions extends BxTemplVote
         return $this->_sDefault;
     }
 
+    /**
+     * Default reaction name from settings, '' when there is none.
+     */
+    public function getDefaultReaction()
+    {
+        return $this->_sDefaultReaction;
+    }
+
+    public function getMode()
+    {
+        return $this->_sMode;
+    }
+
+    public function getPerUser()
+    {
+        return $this->_iPerUser;
+    }
+
+    public function isSplitCounters()
+    {
+        return $this->_bSplitCounters;
+    }
+
+    /**
+     * Any emoji may be given, not only the reactions on the list.
+     */
+    public function isFreeEmojiAllowed()
+    {
+        return $this->_sMode == self::$sModeMany;
+    }
+
     public function getReaction($sName)
     {
-        if(empty($this->_aDataList))
-            $this->getReactions();
+        if(!empty($this->_aDataList[$sName]))
+            return $this->_aDataList[$sName];
 
-        return !empty($this->_aDataList[$sName]) ? $this->_aDataList[$sName] : false;
+        //--- Left out by Solo mode, but people may still have it.
+        if(!empty($this->_aDataListAll[$sName]))
+            return $this->_aDataListAll[$sName];
+
+        //--- A free-picked emoji (Many mode), stored under its code.
+        $sEmoji = self::decodeEmoji($sName);
+        if($sEmoji === false)
+            return false;
+
+        return [
+            'name' => $sName,
+            'title' => $sEmoji,
+            'title_aux' => '',
+            'use' => 'emoji',
+            'icon' => '',
+            'emoji' => $sEmoji,
+            'image' => '',
+            'color' => '',
+            'weight' => 1,
+            'default' => '',
+        ];
     }
 
     public function getReactions($bFullInfo = false)
@@ -118,24 +211,141 @@ class BxDolVoteReactions extends BxTemplVote
     {
         return $this->_oQuery->getTrackBy($aParams);
     }
+
+    /**
+     * Reactions which have a counter in the given vote data: the list, then free-picked emoji (Many mode).
+     */
+    public function getCountedReactions($aVote)
+    {
+        $aNames = [];
+        foreach(array_keys($aVote) as $sKey)
+            if(strncmp($sKey, 'count_', 6) === 0)
+                $aNames[] = substr($sKey, 6);
+
+        return $aNames;
+    }
+
+    /**
+     * The author's reactions to the object, oldest first.
+     */
+    public function getReactionsBy($iObjectId, $iAuthorId)
+    {
+        return $this->_oQuery->getReactionsBy($iObjectId, $iAuthorId);
+    }
+
+    /**
+     * Turn a requested reaction into the name it is stored under, or false when it may not be given:
+     * a name from the list, or (Many mode) a single emoji, given as is or as its e_<hex> code.
+     * An emoji which is on the list is that reaction.
+     */
+    public function normalizeReaction($sReaction)
+    {
+        $sReaction = trim((string)$sReaction);
+        if($sReaction === '')
+            return false;
+
+        if($sReaction === $this->_sDefault)
+            return isset($this->_aDataList[$this->_sDefault]) ? $this->_aDataList[$this->_sDefault]['name'] : false;
+
+        if(isset($this->_aDataList[$sReaction]))
+            return $sReaction;
+
+        if(!$this->isFreeEmojiAllowed())
+            return false;
+
+        $sEmoji = self::decodeEmoji($sReaction);
+        if($sEmoji === false)
+            $sEmoji = $sReaction;
+
+        if(!self::isEmoji($sEmoji))
+            return false;
+
+        $sKey = self::_emojiKey($sEmoji);
+        foreach($this->_aDataList as $sName => $aReaction)
+            if($sName !== $this->_sDefault && !empty($aReaction['emoji']) && self::_emojiKey($aReaction['emoji']) === $sKey)
+                return $sName;
+
+        $sCode = self::encodeEmoji($sEmoji);
+        return get_mb_len($sCode) <= self::$_iReactionLengthMax ? $sCode : false;
+    }
+
+    /**
+     * One emoji: a pictograph (with optional variation selector, skin tone and ZWJ parts),
+     * a keycap or a flag.
+     */
+    public static function isEmoji($s)
+    {
+        $sPart = '(?:\p{Extended_Pictographic}(?:\x{FE0F}|[\x{1F3FB}-\x{1F3FF}])?)';
+        return preg_match('/^(?:' . $sPart . '(?:\x{200D}' . $sPart . ')*|[#*0-9]\x{FE0F}?\x{20E3}|\p{Regional_Indicator}{2})$/u', (string)$s) === 1;
+    }
+
+    /**
+     * e_<hex code points joined with _>, e.g. e_2764_fe0f. Stored as ASCII because the
+     * tables' unicode collation gives every emoji above U+FFFF the same weight.
+     */
+    public static function encodeEmoji($sEmoji)
+    {
+        $aCodes = [];
+        foreach(mb_str_split($sEmoji, 1, 'UTF-8') as $sChar)
+            $aCodes[] = dechex(mb_ord($sChar, 'UTF-8'));
+
+        return self::$_sEmojiCodePrefix . implode('_', $aCodes);
+    }
+
+    /**
+     * The emoji for an e_<hex> code, or false.
+     */
+    public static function decodeEmoji($sCode)
+    {
+        if(!is_string($sCode) || !preg_match('/^e(?:_[0-9a-f]{1,6})+$/', $sCode))
+            return false;
+
+        $aHex = explode('_', $sCode);
+        array_shift($aHex); //--- the 'e' of the prefix
+
+        $sEmoji = '';
+        foreach($aHex as $sHex) {
+            $iCode = hexdec($sHex);
+            if($iCode > 0x10FFFF || ($iCode >= 0xD800 && $iCode <= 0xDFFF))
+                return false;
+
+            $sEmoji .= mb_chr($iCode, 'UTF-8');
+        }
+
+        return self::isEmoji($sEmoji) ? $sEmoji : false;
+    }
+
+    /**
+     * Compare emoji without variation selectors (U+FE0E/U+FE0F).
+     */
+    protected static function _emojiKey($sEmoji)
+    {
+        return preg_replace('/[\x{FE0E}\x{FE0F}]/u', '', (string)$sEmoji);
+    }
+
+    protected function _getReactionOrDefault($sReaction)
+    {
+        $aReaction = $this->getReaction($sReaction);
+        return $aReaction !== false ? $aReaction : $this->_aDataList[$this->_sDefault];
+    }
     
     public function getIcon($sReaction, $bWithColor = true)
     {
-        $aReaction = isset($this->_aDataList[$sReaction]) ? $this->_aDataList[$sReaction] : $this->_aDataList[$this->_sDefault];
+        $aReaction = $this->_getReactionOrDefault($sReaction);
 
     	return $aReaction['icon'] . ($bWithColor && !empty($aReaction['color']) ? ' ' . $aReaction['color'] : '');
     }
     
     public function getEmoji($sReaction, $bWithColor = true)
     {
-        $aReaction = isset($this->_aDataList[$sReaction]) ? $this->_aDataList[$sReaction] : $this->_aDataList[$this->_sDefault];
+        $aReaction = $this->_getReactionOrDefault($sReaction);
 
     	return $aReaction['emoji'];
     }
 
     public function getImage($sReaction, $bWithColor = true)
     {
-        $aReaction = isset($this->_aDataList[$sReaction]) ? $this->_aDataList[$sReaction] : $this->_aDataList[$this->_sDefault];
+        $aReaction = $this->_getReactionOrDefault($sReaction);
 
         list($sIconFont, $sIconUrl, $sIconA, $sIconHtml) = BxTemplFunctions::getInstanceWithTemplate($this->_oTemplate)->getIcon($aReaction['image']);
         if ($sIconUrl)
@@ -165,9 +375,7 @@ class BxDolVoteReactions extends BxTemplVote
         $sReaction = bx_get('reaction');
         if($sReaction !== false) {
             $sReaction = bx_process_input($sReaction);
-            
-            $aReactions = $this->getReactions();
-            if(!in_array($sReaction, $aReactions))
+            if($this->getReaction($sReaction) === false)
                 $sReaction = $this->_sDefault;
 
             $aParams['reaction'] = $sReaction;
@@ -176,9 +384,82 @@ class BxDolVoteReactions extends BxTemplVote
         return $this->_getVotedBy($aParams);
     }
 
+    /**
+     * Give or take back one reaction. A person may hold up to getPerUser() different reactions;
+     * with one allowed, a new reaction replaces the old one. Giving a reaction one already has
+     * takes it back.
+     */
+    public function vote($aVoteData = [], $aRequestParamsData = [])
+    {
+        $iObjectId = $this->getId();
+        $iAuthorId = $this->_getAuthorId();
+        $iAuthorIp = $this->_getAuthorIp();
+
+        $sRequested = isset($aVoteData['reaction']) ? trim((string)$aVoteData['reaction']) : '';
+        $aReactions = $this->getReactionsBy($iObjectId, $iAuthorId);
+
+        //--- One's own reaction can be taken back even when the settings no longer offer it
+        //--- (an emoji picked before Many mode was turned off, a reaction Solo mode leaves out).
+        $sReaction = $this->_getOwnReaction($sRequested, $aReactions);
+        if($sReaction === false)
+            $sReaction = $this->normalizeReaction($sRequested);
+        if($sReaction === false)
+            return ['code' => BX_DOL_OBJECT_ERR_WRONG_DATE, 'message' => _t('_vote_err_reaction_not_available')];
+
+        $aVoteData = ['reaction' => $sReaction, 'value' => $this->getValue()];
+
+        $bUndo = $this->isUndo();
+
+        //--- Take back a reaction one already has.
+        if(in_array($sReaction, $aReactions)) {
+            if(!$bUndo)
+                return ['code' => BX_DOL_OBJECT_ERR_DUPLICATE, 'message' => _t('_vote_err_duplicate_vote')];
+
+            $aTrack = $this->_oQuery->getTrackReaction($iObjectId, $iAuthorId, $sReaction);
+            if(!empty($aTrack['value']))
+                $aVoteData['value'] = (int)$aTrack['value'];
+
+            return $this->_vote($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, true, $aRequestParamsData);
+        }
+
+        if(!$this->isAllowedVote())
+            return ['code' => BX_DOL_OBJECT_ERR_ACCESS_DENIED, 'message' => $this->msgErrAllowedVote()];
+
+        if(count($aReactions) >= $this->_iPerUser) {
+            if(!$bUndo)
+                return ['code' => BX_DOL_OBJECT_ERR_DUPLICATE, 'message' => _t('_vote_err_duplicate_vote')];
+
+            if($this->_iPerUser > 1)
+                return ['code' => BX_DOL_OBJECT_ERR_DUPLICATE, 'message' => _t('_vote_err_reactions_limit', $this->_iPerUser)];
+
+            //--- One reaction per person: the new one replaces the old one.
+            foreach($aReactions as $sReactionOld) {
+                $aTrack = $this->_oQuery->getTrackReaction($iObjectId, $iAuthorId, $sReactionOld);
+                $aResult = $this->_vote($iObjectId, $iAuthorId, $iAuthorIp, [
+                    'reaction' => $sReactionOld,
+                    'value' => !empty($aTrack['value']) ? (int)$aTrack['value'] : $this->getValue()
+                ], true, $aRequestParamsData);
+
+                if((int)$aResult['code'] != 0)
+                    return $aResult;
+            }
+        }
+
+        return $this->_vote($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, false, $aRequestParamsData);
+    }
+
     /**
      * Internal functions
      */
+
+    /**
+     * The track the do-button reflects: the author's latest reaction.
+     */
+    protected function _getTrack($iObjectId, $iAuthorId)
+    {
+        return $this->_oQuery->getTrackLatest($iObjectId, $iAuthorId);
+    }
+
     protected function _isVote($iObjectId = 0, $bForceGet = false)
     {
         $aVote = $this->_getVote($iObjectId, $bForceGet);
@@ -216,56 +497,82 @@ class BxDolVoteReactions extends BxTemplVote
         if($sReaction === false)
             return false;
 
-        $aData['reaction'] = bx_process_input($sReaction);
+        $sRequested = trim((string)bx_process_input($sReaction));
+        $sReaction = $this->normalizeReaction($sRequested);
+        if($sReaction === false)
+            $sReaction = $this->_getOwnReaction($sRequested, $this->getReactionsBy($this->getId(), $this->_getAuthorId()));
+        if($sReaction === false)
+            return false;
+
+        $aData['reaction'] = $sReaction;
         return $aData;
     }
 
-    protected function _returnVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bVoted, $aParams = [])
+    /**
+     * The stored name of the author's own reaction which `$sRequested` names (by its name, e_<hex> code or emoji),
+     * or false. One's own reaction can always be taken back, even when the settings no longer offer it.
+     */
+    protected function _getOwnReaction($sRequested, $aReactions)
     {
-        $aReactions = $this->getReactions(true);
-        $sReaction = $aData['reaction'];
+        if(in_array($sRequested, $aReactions, true))
+            return $sRequested;
+
+        if(self::isEmoji($sRequested) && in_array(($sCode = self::encodeEmoji($sRequested)), $aReactions, true))
+            return $sCode;
+
+        return false;
+    }
 
+    protected function _returnVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bVoted, $aParams = [])
+    {
+        $sReaction = $aData['reaction']; //--- the reaction just given or taken back
         $bUndo = $this->isUndo();
-        $bDisabled = $bVoted && !$bUndo;
 
-        $aVote = $this->_getVote($iObjectId, true);
-        $aTrack = $bVoted ? $this->_getTrack($iObjectId, $iAuthorId) : [];
+        //--- The author's state after the change: all their reactions, the latest one on the button.
+        $aReactionsBy = $this->getReactionsBy($iObjectId, $iAuthorId);
+        $bVotedAny = !empty($aReactionsBy);
+        $bDisabled = $bVotedAny && !$bUndo && count($aReactionsBy) >= $this->_iPerUser;
+
+        $aTrack = $bVotedAny ? $this->_getTrack($iObjectId, $iAuthorId) : [];
+        $sShown = $bVotedAny ? $aTrack['reaction'] : $this->_sDefault;
+        $aShown = $this->_getReactionOrDefault($sShown);
 
-        $sSwitchTo = $bVoted ? $sReaction : $this->_sDefault;
+        $aVote = $this->_getVote($iObjectId, true);
 
-        $sLabelUse = !empty($aReactions[$sSwitchTo]['use']) ? $aReactions[$sSwitchTo]['use'] : 'emoji';
-        $sLabelIcon = $this->_getIconDoWithTrack($bVoted, $aTrack);
-        if(!$bVoted && $sLabelUse != 'icon') {
+        $sLabelUse = !empty($aShown['use']) ? $aShown['use'] : 'emoji';
+        $sLabelIcon = $this->_getIconDoWithTrack($bVotedAny, $aTrack);
+        if(!$bVotedAny && $sLabelUse != 'icon') {
             $sLabelUse = 'icon';
             $sLabelIcon = $this->_oTemplate->parseIcon($sLabelIcon);
         }
 
         $sJsClick = '';
         if(!$bDisabled)
-            $sJsClick = $bVoted && $bUndo ? $this->getJsClickDo($sReaction) : $this->getJsClick();
+            $sJsClick = $bVotedAny && $bUndo ? $this->getJsClickDo($sShown) : $this->getJsClick();
 
         $iTotalC = $iTotalS = 0;
-        foreach(array_keys($aReactions) as $sName) {
-            $iTotalC += (int)$aVote['count_' . $sName];
-            $iTotalS += (int)$aVote['sum_' . $sName];
-        }
+        foreach($aVote as $sKey => $mixedValue)
+            if(strncmp($sKey, 'count_', 6) === 0)
+                $iTotalC += (int)$mixedValue;
+            else if(strncmp($sKey, 'sum_', 4) === 0)
+                $iTotalS += (int)$mixedValue;
         $fTotalR = $iTotalC != 0 ? round($iTotalS / $iTotalC, 2) : 0;
 
-        $iCount = (int)$aVote['count_' . $sReaction];
+        $iCount = isset($aVote['count_' . $sReaction]) ? (int)$aVote['count_' . $sReaction] : 0;
         $aResult = [
             'code' => 0,
-            'reaction' => $this->_bIsApi ? $aReactions[$sSwitchTo]['name'] : $sReaction,
-            'rate' => $aVote['rate_' . $sReaction],
+            'reaction' => $this->_bIsApi ? $aShown['name'] : $sReaction,
+            'rate' => isset($aVote['rate_' . $sReaction]) ? $aVote['rate_' . $sReaction] : 0,
             'count' => $iCount,
             'countf' => $iCount > 0 ? $this->_getCounterLabel($iCount, array('reaction' => $sReaction)) : '',
             'label_use' => $sLabelUse,
             'label_icon' => $sLabelIcon,
-            'label_emoji' => $this->_getEmojiDoWithTrack($bVoted, $aTrack),
-            'label_image' => $this->_getImageDoWithTrack($bVoted, $aTrack),
-            'label_title' => _t($this->_getTitleDoWithTrack($bVoted, $aTrack)),
+            'label_emoji' => $this->_getEmojiDoWithTrack($bVotedAny, $aTrack),
+            'label_image' => $this->_getImageDoWithTrack($bVotedAny, $aTrack),
+            'label_title' => _t($this->_getTitleDoWithTrack($bVotedAny, $aTrack)),
             'label_click' => $sJsClick,
-            'voted' => $bVoted,
-            'disabled' => $bVoted && !$this->isUndo(),
+            'voted' => $bVotedAny,
+            'disabled' => $bDisabled,
             'total' => [
                 'rate' => $fTotalR,
                 'count' => $iTotalC,
@@ -273,18 +580,16 @@ class BxDolVoteReactions extends BxTemplVote
             ]
         ];
 
-        $sDefault = $this->getDefault();
-        $aDefault = $this->getReaction($sDefault);
-        $aDefaultInfo = $this->getReaction($aDefault['name']);
-
         $aResult['api'] = [
             'performer_id' => $iAuthorId,
-            'is_voted' => $aResult['voted'],
-            'is_disabled' => $aResult['disabled'],
-            'reaction' => $aResult['voted'] ? $aResult['reaction'] : $sDefault,
-            'icon' => !empty($aResult['label_emoji']) ? $aResult['label_emoji'] : $aDefaultInfo['emoji'],
+            'is_voted' => $bVotedAny,
+            'is_disabled' => $bDisabled,
+            'reaction' => $bVotedAny ? $sShown : $this->_sDefault,
+            'reactions' => $aReactionsBy,
+            'default' => $this->_sDefaultReaction,
+            'icon' => !empty($aResult['label_emoji']) ? $aResult['label_emoji'] : $aShown['emoji'],
             'title' => !empty($aResult['label_title']) ? $aResult['label_title'] : '',
-            'counter' => $this->getVote()
+            'counter' => $aVote
         ];
 
         return $aResult;
@@ -320,7 +625,7 @@ class BxDolVoteReactions extends BxTemplVote
     {
         $sReaction = $bVoted ? $aTrack['reaction'] : $this->_sDefault;
 
-    	return $this->_aDataList[$sReaction]['title'];
+    	return $this->_getReactionOrDefault($sReaction)['title'];
     }
 
     protected function _getTitleDoBy($aParams = [])
@@ -329,7 +634,7 @@ class BxDolVoteReactions extends BxTemplVote
             return _t('_vote_do_by_reactions');
 
         $sReaction = !empty($aParams['reaction']) ? $aParams['reaction'] : $this->_sDefault;
-    	return _t('_vote_do_by_x_reaction', _t($this->_aDataList[$sReaction]['title']));
+    	return _t('_vote_do_by_x_reaction', _t($this->_getReactionOrDefault($sReaction)['title']));
     }
 }
 

+ 61 - 5
inc/classes/BxDolVoteReactionsQuery.php

@@ -20,7 +20,13 @@ class BxDolVoteReactionsQuery extends BxDolVoteQuery
     public function getVote($iObjectId)
     {
         $aReactions = $this->_oModule->getReactions();
-        $aReactionsGot = $this->getAllWithKey('SELECT * FROM ' . $this->_sTable . ' WHERE `object_id` = :object_id', 'reaction', array('object_id' => $iObjectId));
+        $aReactionsGot = $this->getAllWithKey('SELECT * FROM ' . $this->_sTable . ' WHERE `object_id` = :object_id ORDER BY `count` DESC', 'reaction', array('object_id' => $iObjectId));
+
+        // Free-picked emoji (Many mode) are counted under their code; add the ones this object has.
+        if($this->_oModule->isFreeEmojiAllowed())
+            foreach($aReactionsGot as $sName => $aRow)
+                if((int)$aRow['count'] > 0 && !in_array($sName, $aReactions) && $this->_oModule->getReaction($sName) !== false)
+                    $aReactions[] = $sName;
 
         $aResult = array();
         foreach($aReactions as $sName) {
@@ -62,7 +68,7 @@ class BxDolVoteReactionsQuery extends BxDolVoteQuery
             return false;
 
         if($bUndo)
-            return $this->_deleteTrack($iObjectId, $iAuthorId);
+            return $this->_deleteTrackReaction($iObjectId, $iAuthorId, $sReaction);
 
         $iNow = time();
         $iAuthorNip = bx_get_ip_hash($sAuthorIp);
@@ -73,6 +79,53 @@ class BxDolVoteReactionsQuery extends BxDolVoteQuery
         return false;
     }
 
+    /**
+     * The author's reactions to the object, oldest first.
+     */
+    public function getReactionsBy($iObjectId, $iAuthorId)
+    {
+        return $this->getColumn("SELECT `reaction` FROM `{$this->_sTableTrack}` WHERE `object_id` = :object_id AND `author_id` = :author_id ORDER BY `id` ASC", [
+            'object_id' => $iObjectId,
+            'author_id' => $iAuthorId
+        ]);
+    }
+
+    /**
+     * The author's latest reaction to the object (what the do-button shows).
+     */
+    public function getTrackLatest($iObjectId, $iAuthorId)
+    {
+        return $this->getRow("SELECT * FROM `{$this->_sTableTrack}` WHERE `object_id` = :object_id AND `author_id` = :author_id ORDER BY `id` DESC LIMIT 1", [
+            'object_id' => $iObjectId,
+            'author_id' => $iAuthorId
+        ]);
+    }
+
+    public function getTrackReaction($iObjectId, $iAuthorId, $sReaction)
+    {
+        return $this->getRow("SELECT * FROM `{$this->_sTableTrack}` WHERE `object_id` = :object_id AND `author_id` = :author_id AND `reaction` = :reaction LIMIT 1", [
+            'object_id' => $iObjectId,
+            'author_id' => $iAuthorId,
+            'reaction' => $sReaction
+        ]);
+    }
+
+    protected function _deleteTrackReaction($iObjectId, $iAuthorId, $sReaction)
+    {
+        $iId = (int)$this->getOne("SELECT `id` FROM `{$this->_sTableTrack}` WHERE `object_id` = :object_id AND `author_id` = :author_id AND `reaction` = :reaction LIMIT 1", [
+            'object_id' => $iObjectId,
+            'author_id' => $iAuthorId,
+            'reaction' => $sReaction
+        ]);
+        if(!$iId)
+            return false;
+
+        if((int)$this->query("DELETE FROM `{$this->_sTableTrack}` WHERE `id` = :id LIMIT 1", ['id' => $iId]) > 0)
+            return $iId;
+
+        return false;
+    }
+
     public function getSqlParts($sMainTable, $sMainField)
     {
         if(empty($this->_sTable) || empty($sMainTable) || empty($sMainField))
@@ -138,9 +191,12 @@ class BxDolVoteReactionsQuery extends BxDolVoteQuery
     protected function _updateTriggerTable($iObjectId, $aEntry)
     {
         $iCounted = $iTotalCount = $fTotalRate = 0;
-        $aReactions = $this->_oModule->getReactions();
-        foreach($aReactions as $sName) {
-            $iCount = (int)$aEntry['count_' . $sName];
+        foreach($aEntry as $sKey => $mixedValue) {
+            if(strncmp($sKey, 'count_', 6) !== 0)
+                continue;
+
+            $sName = substr($sKey, 6);
+            $iCount = (int)$mixedValue;
             if($iCount == 0)
                 continue;
 

+ 12 - 2
install/sql/system.sql

@@ -570,8 +570,6 @@ INSERT INTO `sys_options`(`category_id`, `name`, `caption`, `info`, `value`, `ty
 (@iCategoryId, 'sys_treat_cxt_in_cxt_as_cnt', '_adm_stg_cpt_option_sys_treat_cxt_in_cxt_as_cnt', '', 'on', 'checkbox', '', '', '', 52),
 (@iCategoryId, 'sys_check_fan_in_parent_context', '_adm_stg_cpt_option_sys_check_fan_in_parent_context', '', 'on', 'checkbox', '', '', '', 55),
 
-(@iCategoryId, 'sys_vote_reactions_quick_mode', '_adm_stg_cpt_option_sys_vote_reactions_quick_mode', '', 'on', 'checkbox', '', '', '', 60),
-
 (@iCategoryId, 'sys_cmts_enable_auto_approve', '_adm_stg_cpt_option_sys_cmts_enable_auto_approve', '', 'on', 'checkbox', '', '', '', 70),
 
 (@iCategoryId, 'sys_create_post_form_preloading_list', '_adm_stg_cpt_option_sys_create_post_form_preloading_list', '_adm_stg_inf_option_sys_create_post_form_preloading_list', '', 'list', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:44:"get_options_create_post_form_preloading_list";s:5:"class";s:13:"TemplServices";}', '', '', 80);
@@ -843,6 +841,18 @@ INSERT INTO `sys_options`(`category_id`, `name`, `caption`, `value`, `type`, `ex
 (@iCategoryId, 'sys_sockets_key', '_adm_stg_cpt_option_sys_sockets_key', '', 'digit', '', '', '', 4),
 (@iCategoryId, 'sys_sockets_secret', '_adm_stg_cpt_option_sys_sockets_secret', '', 'secret', '', '', '', 5);
 
+--
+-- CATEGORY: Reactions
+--
+INSERT INTO `sys_options_categories`(`type_id`, `name`, `caption`, `hidden`, `order`) VALUES (@iTypeId, 'reactions', '_adm_stg_cpt_category_reactions', 0, 23);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options`(`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_error`, `order`) VALUES
+(@iCategoryId, 'sys_vote_reactions_mode', '_adm_stg_cpt_option_sys_vote_reactions_mode', '_adm_stg_inf_option_sys_vote_reactions_mode', 'few', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:31:"get_options_vote_reactions_mode";s:5:"class";s:13:"TemplServices";}', '', '', 1),
+(@iCategoryId, 'sys_vote_reactions_default', '_adm_stg_cpt_option_sys_vote_reactions_default', '_adm_stg_inf_option_sys_vote_reactions_default', 'like', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:34:"get_options_vote_reactions_default";s:5:"class";s:13:"TemplServices";}', '', '', 2),
+(@iCategoryId, 'sys_vote_reactions_per_user', '_adm_stg_cpt_option_sys_vote_reactions_per_user', '_adm_stg_inf_option_sys_vote_reactions_per_user', '1', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:35:"get_options_vote_reactions_per_user";s:5:"class";s:13:"TemplServices";}', '', '', 3),
+(@iCategoryId, 'sys_vote_reactions_split_counters', '_adm_stg_cpt_option_sys_vote_reactions_split_counters', '_adm_stg_inf_option_sys_vote_reactions_split_counters', '', 'checkbox', '', '', '', 4);
+
 --
 -- CATEGORY (HIDDEN): Audit
 --

+ 14 - 0
modules/boonex/english/data/langs/system/en.xml

@@ -1899,6 +1899,10 @@
     <string name="_adm_stg_inf_option_sys_live_search_limit"><![CDATA[Set the maximum number of results displayed in Live Search suggestion box.]]></string>
     <string name="_adm_stg_cpt_option_sys_profiles_search_limit"><![CDATA[Profile Search Results]]></string>
     <string name="_adm_stg_inf_option_sys_profiles_search_limit"><![CDATA[Set the maximum number of results displayed per page in Profile Search.]]></string>
+    <string name="_adm_stg_inf_option_sys_vote_reactions_default"><![CDATA[Given with a single click or tap; other reactions are a hover or long press away. With none, the reaction button always opens the choice of reactions.]]></string>
+    <string name="_adm_stg_inf_option_sys_vote_reactions_mode"><![CDATA[Solo: one reaction, the default one, given with a click. Few: a short list of reactions to choose from. Many: any emoji, chosen in an emoji picker.]]></string>
+    <string name="_adm_stg_inf_option_sys_vote_reactions_per_user"><![CDATA[How many different reactions one person may give to the same content. Works in the Few and Many modes.]]></string>
+    <string name="_adm_stg_inf_option_sys_vote_reactions_split_counters"><![CDATA[Show a separate counter for each reaction instead of one total.]]></string>
     <string name="_adm_stg_cpt_option_sys_per_page_browse_showcase"><![CDATA[Number of items in showcase view]]></string>
     <string name="_adm_stg_inf_option_sys_per_page_browse_showcase"><![CDATA[Set the maximum number of results displayed in horizontal carousel.]]></string>
     <string name="_adm_stg_cpt_option_sys_session_lifetime_in_min"><![CDATA[Session lifetime (min)]]></string>
@@ -1944,7 +1948,14 @@
     <string name="_adm_stg_inf_option_sys_profile_bot"><![CDATA[Choose profile which will be used as an author of system actions.]]></string>
     <string name="_adm_stg_cpt_option_sys_logs_storage_default"><![CDATA[Default logs storage]]></string>
     <string name="_adm_stg_cpt_option_sys_logs_level"><![CDATA[Log Level]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_default"><![CDATA[Default reaction]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_mode"><![CDATA[Reactions mode]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_mode_few"><![CDATA[Few]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_mode_many"><![CDATA[Many]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_mode_solo"><![CDATA[Solo]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_per_user"><![CDATA[Reactions per person]]></string>
     <string name="_adm_stg_cpt_option_sys_vote_reactions_quick_mode"><![CDATA[Enable 'Quick Reactions' mode - vote with default reaction when clicked]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_split_counters"><![CDATA[Split counters]]></string>
     <string name="_adm_stg_cpt_option_sys_cmts_enable_auto_approve"><![CDATA[Auto approve a comment after creation]]></string>
     <string name="_adm_stg_cpt_option_sys_create_post_form_preloading_list"><![CDATA[Asynchronous forms preload in "Create Post" block]]></string>
     <string name="_adm_stg_inf_option_sys_create_post_form_preloading_list"><![CDATA[]]></string>
@@ -1991,6 +2002,7 @@
     <string name="_adm_stg_cpt_option_sys_pwa_manifest_theme_color"><![CDATA[Theme color]]></string>
 
     <string name="_adm_stg_cpt_category_pwa_sw"><![CDATA[Service Worker]]></string>
+    <string name="_adm_stg_cpt_category_reactions"><![CDATA[Reactions]]></string>
     <string name="_adm_stg_cpt_option_sys_pwa_sw_enable"><![CDATA[Enable]]></string>
     <string name="_adm_stg_cpt_option_sys_pwa_sw_cache"><![CDATA[URLs to cache on install (each in new line)]]></string>
     <string name="_adm_stg_cpt_option_sys_pwa_sw_offline"><![CDATA[Offline page]]></string>
@@ -4497,6 +4509,8 @@
         <string name="_vote_do_by_x_reaction"><![CDATA["{0}" by]]></string>
         <string name="_vote_do_by_summary"><![CDATA[All]]></string>
 	<string name="_vote_err_not_enabled"><![CDATA[The feature is not available right now. Please, try again later.]]></string>
+	<string name="_vote_err_reaction_not_available"><![CDATA[This reaction is not available.]]></string>
+	<string name="_vote_err_reactions_limit"><![CDATA[You can give up to {0} reactions.]]></string>
 	<string name="_vote_err_duplicate_vote"><![CDATA[Duplicate vote]]></string>
         <string name="_vote_n_votes"><![CDATA[{0} votes]]></string>
 	<string name="_week_fri"><![CDATA[Friday]]></string>

+ 14 - 0
modules/boonex/russian/data/langs/system/ru.xml

@@ -1871,6 +1871,10 @@
     <string name="_adm_stg_inf_option_sys_live_search_limit"><![CDATA[Установите максимальное количество результатов, отображаемых в поле предложений интерактивного поиска.]]></string>
     <string name="_adm_stg_cpt_option_sys_profiles_search_limit"><![CDATA[Результаты поиска профилей]]></string>
     <string name="_adm_stg_inf_option_sys_profiles_search_limit"><![CDATA[Установите максимальное количество результатов, отображаемых на странице поиска профиля.]]></string>
+    <string name="_adm_stg_inf_option_sys_vote_reactions_default"><![CDATA[Ставится одним нажатием, остальные реакции открываются наведением или долгим нажатием. Если не выбрана, кнопка реакций всегда открывает выбор.]]></string>
+    <string name="_adm_stg_inf_option_sys_vote_reactions_mode"><![CDATA[Одна: только реакция по умолчанию, ставится одним нажатием. Несколько: короткий список реакций на выбор. Много: любой эмодзи из полного списка.]]></string>
+    <string name="_adm_stg_inf_option_sys_vote_reactions_per_user"><![CDATA[Сколько разных реакций один человек может поставить одному материалу. Работает в режимах «Несколько» и «Много».]]></string>
+    <string name="_adm_stg_inf_option_sys_vote_reactions_split_counters"><![CDATA[Показывать отдельный счётчик для каждой реакции вместо общего.]]></string>
     <string name="_adm_stg_cpt_option_sys_per_page_browse_showcase"><![CDATA[Количество позиций при витринном отображении]]></string>
     <string name="_adm_stg_inf_option_sys_per_page_browse_showcase"><![CDATA[Установите максимальное количество результатов, отображаемых в горизонтальной карусели.]]></string>
     <string name="_adm_stg_cpt_option_sys_session_auth"><![CDATA[Аутентификация через сессию]]></string>
@@ -1915,7 +1919,14 @@
     <string name="_adm_stg_inf_option_sys_profile_bot"><![CDATA[Выберите профиль, который будет использоваться в качестве автора системных действий.]]></string>
     <string name="_adm_stg_cpt_option_sys_logs_storage_default"><![CDATA[Система логирования по умолчанию]]></string>
     <string name="_adm_stg_cpt_option_sys_logs_level"><![CDATA[Уровень логирования]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_default"><![CDATA[Реакция по умолчанию]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_mode"><![CDATA[Режим реакций]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_mode_few"><![CDATA[Несколько]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_mode_many"><![CDATA[Много]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_mode_solo"><![CDATA[Одна]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_per_user"><![CDATA[Реакций от одного человека]]></string>
     <string name="_adm_stg_cpt_option_sys_vote_reactions_quick_mode"><![CDATA[Включить режим 'Быстрых реакций' - голосовать при клике, используя реакцию по умолчанию]]></string>
+    <string name="_adm_stg_cpt_option_sys_vote_reactions_split_counters"><![CDATA[Отдельные счётчики]]></string>
     <string name="_adm_stg_cpt_option_sys_cmts_enable_auto_approve"><![CDATA[Автоматически активировать комментарий после создания]]></string>
     <string name="_adm_stg_cpt_option_sys_create_post_form_preloading_list"><![CDATA[Асинхронная предварительная загрузка форм в блоке "Создать публикацию"]]></string>
     <string name="_adm_stg_inf_option_sys_create_post_form_preloading_list"><![CDATA[]]></string>
@@ -1962,6 +1973,7 @@
     <string name="_adm_stg_cpt_option_sys_pwa_manifest_theme_color"><![CDATA[Цвет темы]]></string>
 
     <string name="_adm_stg_cpt_category_pwa_sw"><![CDATA[Service Worker]]></string>
+    <string name="_adm_stg_cpt_category_reactions"><![CDATA[Реакции]]></string>
     <string name="_adm_stg_cpt_option_sys_pwa_sw_enable"><![CDATA[Включить]]></string>
     <string name="_adm_stg_cpt_option_sys_pwa_sw_cache"><![CDATA[URL-адреса для кэширования при установке (каждый в новой строке)]]></string>
     <string name="_adm_stg_cpt_option_sys_pwa_sw_offline"><![CDATA[Офлайн-страница]]></string>
@@ -4418,6 +4430,8 @@
         <string name="_vote_do_by_x_reaction"><![CDATA["{0}" от]]></string>
         <string name="_vote_do_by_summary"><![CDATA[Все]]></string>
 	<string name="_vote_err_not_enabled"><![CDATA[Ваш голос не может быть принят в данный момент. Пожалуйста, попробуйте позднее.]]></string>
+	<string name="_vote_err_reaction_not_available"><![CDATA[Эта реакция недоступна.]]></string>
+	<string name="_vote_err_reactions_limit"><![CDATA[Можно поставить не больше {0} реакций.]]></string>
 	<string name="_vote_err_duplicate_vote"><![CDATA[Повторный голос]]></string>
         <string name="_vote_n_votes"><![CDATA[{0} голосов]]></string>
 	<string name="_week_fri"><![CDATA[Пятница]]></string>

+ 2 - 1
neo/package.json

@@ -15,7 +15,8 @@
     "lerna-update-wizard": "^1.1.2",
     "patch-package": "^8.0.1",
     "turbo": "^2.5.8",
-    "typescript": "~6.0.3"
+    "typescript": "~6.0.3",
+    "unicode-emoji-json": "0.9.0"
   },
   "scripts": {
     "lint": "eslint apps packages scripts crypto-shim",

+ 15 - 1
neo/packages/app/default/translations/de.json

@@ -524,5 +524,19 @@
     "Align right": "Rechtsbündig",
     "Indentation": "Einzug",
     "Indent": "Einzug vergrößern",
-    "Outdent": "Einzug verkleinern"
+    "Outdent": "Einzug verkleinern",
+    "React": "Reagieren",
+    "Reacted": "Reagiert",
+    "Search emoji": "Emoji suchen",
+    "No emoji found": "Keine Emoji gefunden",
+    "You can give up to {{count}} reactions": "Du kannst bis zu {{count}} Reaktionen vergeben",
+    "Smileys & Emotion": "Smileys & Emotionen",
+    "People & Body": "Menschen & Körper",
+    "Animals & Nature": "Tiere & Natur",
+    "Food & Drink": "Essen & Trinken",
+    "Travel & Places": "Reisen & Orte",
+    "Activities": "Aktivitäten",
+    "Objects": "Objekte",
+    "Symbols": "Symbole",
+    "Flags": "Flaggen"
 }

+ 15 - 1
neo/packages/app/default/translations/es.json

@@ -524,5 +524,19 @@
     "Align right": "Alinear a la derecha",
     "Indentation": "Sangría",
     "Indent": "Aumentar sangría",
-    "Outdent": "Reducir sangría"
+    "Outdent": "Reducir sangría",
+    "React": "Reaccionar",
+    "Reacted": "Reaccionaste",
+    "Search emoji": "Buscar emoji",
+    "No emoji found": "No se encontraron emojis",
+    "You can give up to {{count}} reactions": "Puedes dar hasta {{count}} reacciones",
+    "Smileys & Emotion": "Caritas y emociones",
+    "People & Body": "Personas y cuerpo",
+    "Animals & Nature": "Animales y naturaleza",
+    "Food & Drink": "Comida y bebida",
+    "Travel & Places": "Viajes y lugares",
+    "Activities": "Actividades",
+    "Objects": "Objetos",
+    "Symbols": "Símbolos",
+    "Flags": "Banderas"
 }

+ 15 - 1
neo/packages/app/default/translations/fr.json

@@ -524,5 +524,19 @@
     "Align right": "Aligner à droite",
     "Indentation": "Retrait",
     "Indent": "Augmenter le retrait",
-    "Outdent": "Diminuer le retrait"
+    "Outdent": "Diminuer le retrait",
+    "React": "Réagir",
+    "Reacted": "Réaction ajoutée",
+    "Search emoji": "Rechercher un emoji",
+    "No emoji found": "Aucun emoji trouvé",
+    "You can give up to {{count}} reactions": "Vous pouvez donner jusqu’à {{count}} réactions",
+    "Smileys & Emotion": "Smileys et émotions",
+    "People & Body": "Personnes et corps",
+    "Animals & Nature": "Animaux et nature",
+    "Food & Drink": "Nourriture et boissons",
+    "Travel & Places": "Voyages et lieux",
+    "Activities": "Activités",
+    "Objects": "Objets",
+    "Symbols": "Symboles",
+    "Flags": "Drapeaux"
 }

+ 15 - 1
neo/packages/app/default/translations/ru.json

@@ -526,5 +526,19 @@
     "Align right": "По правому краю",
     "Indentation": "Отступ",
     "Indent": "Увеличить отступ",
-    "Outdent": "Уменьшить отступ"
+    "Outdent": "Уменьшить отступ",
+    "React": "Реакция",
+    "Reacted": "Есть реакция",
+    "Search emoji": "Найти эмодзи",
+    "No emoji found": "Эмодзи не найдены",
+    "You can give up to {{count}} reactions": "Реакций можно поставить не больше: {{count}}",
+    "Smileys & Emotion": "Смайлики и эмоции",
+    "People & Body": "Люди и тело",
+    "Animals & Nature": "Животные и природа",
+    "Food & Drink": "Еда и напитки",
+    "Travel & Places": "Путешествия и места",
+    "Activities": "Занятия",
+    "Objects": "Предметы",
+    "Symbols": "Символы",
+    "Flags": "Флаги"
 }

+ 1919 - 0
neo/packages/app/lib/emoji/emoji-data.ts

@@ -0,0 +1,1919 @@
+// Generated by scripts/generate-emoji-data.js from unicode-emoji-json 0.9.0. Do not edit.
+// 1852 emoji up to Emoji 15.0, without skin-tone variants.
+
+export type EmojiEntry = readonly [emoji: string, name: string];
+
+export type EmojiGroup = {
+    id: string;
+    title: string;
+    emojis: readonly EmojiEntry[];
+};
+
+export const EMOJI_GROUPS: readonly EmojiGroup[] = [
+    {
+        id: "smileys_emotion",
+        title: "Smileys & Emotion",
+        emojis: [
+            ["😀","grinning face"],
+            ["😃","grinning face with big eyes"],
+            ["😄","grinning face with smiling eyes"],
+            ["😁","beaming face with smiling eyes"],
+            ["😆","grinning squinting face"],
+            ["😅","grinning face with sweat"],
+            ["🤣","rolling on the floor laughing"],
+            ["😂","face with tears of joy"],
+            ["🙂","slightly smiling face"],
+            ["🙃","upside-down face"],
+            ["🫠","melting face"],
+            ["😉","winking face"],
+            ["😊","smiling face with smiling eyes"],
+            ["😇","smiling face with halo"],
+            ["🥰","smiling face with hearts"],
+            ["😍","smiling face with heart-eyes"],
+            ["🤩","star-struck"],
+            ["😘","face blowing a kiss"],
+            ["😗","kissing face"],
+            ["☺️","smiling face"],
+            ["😚","kissing face with closed eyes"],
+            ["😙","kissing face with smiling eyes"],
+            ["🥲","smiling face with tear"],
+            ["😋","face savoring food"],
+            ["😛","face with tongue"],
+            ["😜","winking face with tongue"],
+            ["🤪","zany face"],
+            ["😝","squinting face with tongue"],
+            ["🤑","money-mouth face"],
+            ["🤗","smiling face with open hands"],
+            ["🤭","face with hand over mouth"],
+            ["🫢","face with open eyes and hand over mouth"],
+            ["🫣","face with peeking eye"],
+            ["🤫","shushing face"],
+            ["🤔","thinking face"],
+            ["🫡","saluting face"],
+            ["🤐","zipper-mouth face"],
+            ["🤨","face with raised eyebrow"],
+            ["😐","neutral face"],
+            ["😑","expressionless face"],
+            ["😶","face without mouth"],
+            ["🫥","dotted line face"],
+            ["😶‍🌫️","face in clouds"],
+            ["😏","smirking face"],
+            ["😒","unamused face"],
+            ["🙄","face with rolling eyes"],
+            ["😬","grimacing face"],
+            ["😮‍💨","face exhaling"],
+            ["🤥","lying face"],
+            ["🫨","shaking face"],
+            ["😌","relieved face"],
+            ["😔","pensive face"],
+            ["😪","sleepy face"],
+            ["🤤","drooling face"],
+            ["😴","sleeping face"],
+            ["😷","face with medical mask"],
+            ["🤒","face with thermometer"],
+            ["🤕","face with head-bandage"],
+            ["🤢","nauseated face"],
+            ["🤮","face vomiting"],
+            ["🤧","sneezing face"],
+            ["🥵","hot face"],
+            ["🥶","cold face"],
+            ["🥴","woozy face"],
+            ["😵","face with crossed-out eyes"],
+            ["😵‍💫","face with spiral eyes"],
+            ["🤯","exploding head"],
+            ["🤠","cowboy hat face"],
+            ["🥳","partying face"],
+            ["🥸","disguised face"],
+            ["😎","smiling face with sunglasses"],
+            ["🤓","nerd face"],
+            ["🧐","face with monocle"],
+            ["😕","confused face"],
+            ["🫤","face with diagonal mouth"],
+            ["😟","worried face"],
+            ["🙁","slightly frowning face"],
+            ["☹️","frowning face"],
+            ["😮","face with open mouth"],
+            ["😯","hushed face"],
+            ["😲","astonished face"],
+            ["😳","flushed face"],
+            ["🥺","pleading face"],
+            ["🥹","face holding back tears"],
+            ["😦","frowning face with open mouth"],
+            ["😧","anguished face"],
+            ["😨","fearful face"],
+            ["😰","anxious face with sweat"],
+            ["😥","sad but relieved face"],
+            ["😢","crying face"],
+            ["😭","loudly crying face"],
+            ["😱","face screaming in fear"],
+            ["😖","confounded face"],
+            ["😣","persevering face"],
+            ["😞","disappointed face"],
+            ["😓","downcast face with sweat"],
+            ["😩","weary face"],
+            ["😫","tired face"],
+            ["🥱","yawning face"],
+            ["😤","face with steam from nose"],
+            ["😡","enraged face"],
+            ["😠","angry face"],
+            ["🤬","face with symbols on mouth"],
+            ["😈","smiling face with horns"],
+            ["👿","angry face with horns"],
+            ["💀","skull"],
+            ["☠️","skull and crossbones"],
+            ["💩","pile of poo"],
+            ["🤡","clown face"],
+            ["👹","ogre"],
+            ["👺","goblin"],
+            ["👻","ghost"],
+            ["👽","alien"],
+            ["👾","alien monster"],
+            ["🤖","robot"],
+            ["😺","grinning cat"],
+            ["😸","grinning cat with smiling eyes"],
+            ["😹","cat with tears of joy"],
+            ["😻","smiling cat with heart-eyes"],
+            ["😼","cat with wry smile"],
+            ["😽","kissing cat"],
+            ["🙀","weary cat"],
+            ["😿","crying cat"],
+            ["😾","pouting cat"],
+            ["🙈","see-no-evil monkey"],
+            ["🙉","hear-no-evil monkey"],
+            ["🙊","speak-no-evil monkey"],
+            ["💌","love letter"],
+            ["💘","heart with arrow"],
+            ["💝","heart with ribbon"],
+            ["💖","sparkling heart"],
+            ["💗","growing heart"],
+            ["💓","beating heart"],
+            ["💞","revolving hearts"],
+            ["💕","two hearts"],
+            ["💟","heart decoration"],
+            ["❣️","heart exclamation"],
+            ["💔","broken heart"],
+            ["❤️‍🔥","heart on fire"],
+            ["❤️‍🩹","mending heart"],
+            ["❤️","red heart"],
+            ["🩷","pink heart"],
+            ["🧡","orange heart"],
+            ["💛","yellow heart"],
+            ["💚","green heart"],
+            ["💙","blue heart"],
+            ["🩵","light blue heart"],
+            ["💜","purple heart"],
+            ["🤎","brown heart"],
+            ["🖤","black heart"],
+            ["🩶","grey heart"],
+            ["🤍","white heart"],
+            ["💋","kiss mark"],
+            ["💯","hundred points"],
+            ["💢","anger symbol"],
+            ["💥","collision"],
+            ["💫","dizzy"],
+            ["💦","sweat droplets"],
+            ["💨","dashing away"],
+            ["🕳️","hole"],
+            ["💬","speech balloon"],
+            ["👁️‍🗨️","eye in speech bubble"],
+            ["🗨️","left speech bubble"],
+            ["🗯️","right anger bubble"],
+            ["💭","thought balloon"],
+            ["💤","ZZZ"],
+        ],
+    },
+    {
+        id: "people_body",
+        title: "People & Body",
+        emojis: [
+            ["👋","waving hand"],
+            ["🤚","raised back of hand"],
+            ["🖐️","hand with fingers splayed"],
+            ["✋","raised hand"],
+            ["🖖","vulcan salute"],
+            ["🫱","rightwards hand"],
+            ["🫲","leftwards hand"],
+            ["🫳","palm down hand"],
+            ["🫴","palm up hand"],
+            ["🫷","leftwards pushing hand"],
+            ["🫸","rightwards pushing hand"],
+            ["👌","OK hand"],
+            ["🤌","pinched fingers"],
+            ["🤏","pinching hand"],
+            ["✌️","victory hand"],
+            ["🤞","crossed fingers"],
+            ["🫰","hand with index finger and thumb crossed"],
+            ["🤟","love-you gesture"],
+            ["🤘","sign of the horns"],
+            ["🤙","call me hand"],
+            ["👈","backhand index pointing left"],
+            ["👉","backhand index pointing right"],
+            ["👆","backhand index pointing up"],
+            ["🖕","middle finger"],
+            ["👇","backhand index pointing down"],
+            ["☝️","index pointing up"],
+            ["🫵","index pointing at the viewer"],
+            ["👍","thumbs up"],
+            ["👎","thumbs down"],
+            ["✊","raised fist"],
+            ["👊","oncoming fist"],
+            ["🤛","left-facing fist"],
+            ["🤜","right-facing fist"],
+            ["👏","clapping hands"],
+            ["🙌","raising hands"],
+            ["🫶","heart hands"],
+            ["👐","open hands"],
+            ["🤲","palms up together"],
+            ["🤝","handshake"],
+            ["🙏","folded hands"],
+            ["✍️","writing hand"],
+            ["💅","nail polish"],
+            ["🤳","selfie"],
+            ["💪","flexed biceps"],
+            ["🦾","mechanical arm"],
+            ["🦿","mechanical leg"],
+            ["🦵","leg"],
+            ["🦶","foot"],
+            ["👂","ear"],
+            ["🦻","ear with hearing aid"],
+            ["👃","nose"],
+            ["🧠","brain"],
+            ["🫀","anatomical heart"],
+            ["🫁","lungs"],
+            ["🦷","tooth"],
+            ["🦴","bone"],
+            ["👀","eyes"],
+            ["👁️","eye"],
+            ["👅","tongue"],
+            ["👄","mouth"],
+            ["🫦","biting lip"],
+            ["👶","baby"],
+            ["🧒","child"],
+            ["👦","boy"],
+            ["👧","girl"],
+            ["🧑","person"],
+            ["👱","person blond hair"],
+            ["👨","man"],
+            ["🧔","person beard"],
+            ["🧔‍♂️","man beard"],
+            ["🧔‍♀️","woman beard"],
+            ["👨‍🦰","man red hair"],
+            ["👨‍🦱","man curly hair"],
+            ["👨‍🦳","man white hair"],
+            ["👨‍🦲","man bald"],
+            ["👩","woman"],
+            ["👩‍🦰","woman red hair"],
+            ["🧑‍🦰","person red hair"],
+            ["👩‍🦱","woman curly hair"],
+            ["🧑‍🦱","person curly hair"],
+            ["👩‍🦳","woman white hair"],
+            ["🧑‍🦳","person white hair"],
+            ["👩‍🦲","woman bald"],
+            ["🧑‍🦲","person bald"],
+            ["👱‍♀️","woman blond hair"],
+            ["👱‍♂️","man blond hair"],
+            ["🧓","older person"],
+            ["👴","old man"],
+            ["👵","old woman"],
+            ["🙍","person frowning"],
+            ["🙍‍♂️","man frowning"],
+            ["🙍‍♀️","woman frowning"],
+            ["🙎","person pouting"],
+            ["🙎‍♂️","man pouting"],
+            ["🙎‍♀️","woman pouting"],
+            ["🙅","person gesturing NO"],
+            ["🙅‍♂️","man gesturing NO"],
+            ["🙅‍♀️","woman gesturing NO"],
+            ["🙆","person gesturing OK"],
+            ["🙆‍♂️","man gesturing OK"],
+            ["🙆‍♀️","woman gesturing OK"],
+            ["💁","person tipping hand"],
+            ["💁‍♂️","man tipping hand"],
+            ["💁‍♀️","woman tipping hand"],
+            ["🙋","person raising hand"],
+            ["🙋‍♂️","man raising hand"],
+            ["🙋‍♀️","woman raising hand"],
+            ["🧏","deaf person"],
+            ["🧏‍♂️","deaf man"],
+            ["🧏‍♀️","deaf woman"],
+            ["🙇","person bowing"],
+            ["🙇‍♂️","man bowing"],
+            ["🙇‍♀️","woman bowing"],
+            ["🤦","person facepalming"],
+            ["🤦‍♂️","man facepalming"],
+            ["🤦‍♀️","woman facepalming"],
+            ["🤷","person shrugging"],
+            ["🤷‍♂️","man shrugging"],
+            ["🤷‍♀️","woman shrugging"],
+            ["🧑‍⚕️","health worker"],
+            ["👨‍⚕️","man health worker"],
+            ["👩‍⚕️","woman health worker"],
+            ["🧑‍🎓","student"],
+            ["👨‍🎓","man student"],
+            ["👩‍🎓","woman student"],
+            ["🧑‍🏫","teacher"],
+            ["👨‍🏫","man teacher"],
+            ["👩‍🏫","woman teacher"],
+            ["🧑‍⚖️","judge"],
+            ["👨‍⚖️","man judge"],
+            ["👩‍⚖️","woman judge"],
+            ["🧑‍🌾","farmer"],
+            ["👨‍🌾","man farmer"],
+            ["👩‍🌾","woman farmer"],
+            ["🧑‍🍳","cook"],
+            ["👨‍🍳","man cook"],
+            ["👩‍🍳","woman cook"],
+            ["🧑‍🔧","mechanic"],
+            ["👨‍🔧","man mechanic"],
+            ["👩‍🔧","woman mechanic"],
+            ["🧑‍🏭","factory worker"],
+            ["👨‍🏭","man factory worker"],
+            ["👩‍🏭","woman factory worker"],
+            ["🧑‍💼","office worker"],
+            ["👨‍💼","man office worker"],
+            ["👩‍💼","woman office worker"],
+            ["🧑‍🔬","scientist"],
+            ["👨‍🔬","man scientist"],
+            ["👩‍🔬","woman scientist"],
+            ["🧑‍💻","technologist"],
+            ["👨‍💻","man technologist"],
+            ["👩‍💻","woman technologist"],
+            ["🧑‍🎤","singer"],
+            ["👨‍🎤","man singer"],
+            ["👩‍🎤","woman singer"],
+            ["🧑‍🎨","artist"],
+            ["👨‍🎨","man artist"],
+            ["👩‍🎨","woman artist"],
+            ["🧑‍✈️","pilot"],
+            ["👨‍✈️","man pilot"],
+            ["👩‍✈️","woman pilot"],
+            ["🧑‍🚀","astronaut"],
+            ["👨‍🚀","man astronaut"],
+            ["👩‍🚀","woman astronaut"],
+            ["🧑‍🚒","firefighter"],
+            ["👨‍🚒","man firefighter"],
+            ["👩‍🚒","woman firefighter"],
+            ["👮","police officer"],
+            ["👮‍♂️","man police officer"],
+            ["👮‍♀️","woman police officer"],
+            ["🕵️","detective"],
+            ["🕵️‍♂️","man detective"],
+            ["🕵️‍♀️","woman detective"],
+            ["💂","guard"],
+            ["💂‍♂️","man guard"],
+            ["💂‍♀️","woman guard"],
+            ["🥷","ninja"],
+            ["👷","construction worker"],
+            ["👷‍♂️","man construction worker"],
+            ["👷‍♀️","woman construction worker"],
+            ["🫅","person with crown"],
+            ["🤴","prince"],
+            ["👸","princess"],
+            ["👳","person wearing turban"],
+            ["👳‍♂️","man wearing turban"],
+            ["👳‍♀️","woman wearing turban"],
+            ["👲","person with skullcap"],
+            ["🧕","woman with headscarf"],
+            ["🤵","person in tuxedo"],
+            ["🤵‍♂️","man in tuxedo"],
+            ["🤵‍♀️","woman in tuxedo"],
+            ["👰","person with veil"],
+            ["👰‍♂️","man with veil"],
+            ["👰‍♀️","woman with veil"],
+            ["🤰","pregnant woman"],
+            ["🫃","pregnant man"],
+            ["🫄","pregnant person"],
+            ["🤱","breast-feeding"],
+            ["👩‍🍼","woman feeding baby"],
+            ["👨‍🍼","man feeding baby"],
+            ["🧑‍🍼","person feeding baby"],
+            ["👼","baby angel"],
+            ["🎅","Santa Claus"],
+            ["🤶","Mrs. Claus"],
+            ["🧑‍🎄","Mx Claus"],
+            ["🦸","superhero"],
+            ["🦸‍♂️","man superhero"],
+            ["🦸‍♀️","woman superhero"],
+            ["🦹","supervillain"],
+            ["🦹‍♂️","man supervillain"],
+            ["🦹‍♀️","woman supervillain"],
+            ["🧙","mage"],
+            ["🧙‍♂️","man mage"],
+            ["🧙‍♀️","woman mage"],
+            ["🧚","fairy"],
+            ["🧚‍♂️","man fairy"],
+            ["🧚‍♀️","woman fairy"],
+            ["🧛","vampire"],
+            ["🧛‍♂️","man vampire"],
+            ["🧛‍♀️","woman vampire"],
+            ["🧜","merperson"],
+            ["🧜‍♂️","merman"],
+            ["🧜‍♀️","mermaid"],
+            ["🧝","elf"],
+            ["🧝‍♂️","man elf"],
+            ["🧝‍♀️","woman elf"],
+            ["🧞","genie"],
+            ["🧞‍♂️","man genie"],
+            ["🧞‍♀️","woman genie"],
+            ["🧟","zombie"],
+            ["🧟‍♂️","man zombie"],
+            ["🧟‍♀️","woman zombie"],
+            ["🧌","troll"],
+            ["💆","person getting massage"],
+            ["💆‍♂️","man getting massage"],
+            ["💆‍♀️","woman getting massage"],
+            ["💇","person getting haircut"],
+            ["💇‍♂️","man getting haircut"],
+            ["💇‍♀️","woman getting haircut"],
+            ["🚶","person walking"],
+            ["🚶‍♂️","man walking"],
+            ["🚶‍♀️","woman walking"],
+            ["🧍","person standing"],
+            ["🧍‍♂️","man standing"],
+            ["🧍‍♀️","woman standing"],
+            ["🧎","person kneeling"],
+            ["🧎‍♂️","man kneeling"],
+            ["🧎‍♀️","woman kneeling"],
+            ["🧑‍🦯","person with white cane"],
+            ["👨‍🦯","man with white cane"],
+            ["👩‍🦯","woman with white cane"],
+            ["🧑‍🦼","person in motorized wheelchair"],
+            ["👨‍🦼","man in motorized wheelchair"],
+            ["👩‍🦼","woman in motorized wheelchair"],
+            ["🧑‍🦽","person in manual wheelchair"],
+            ["👨‍🦽","man in manual wheelchair"],
+            ["👩‍🦽","woman in manual wheelchair"],
+            ["🏃","person running"],
+            ["🏃‍♂️","man running"],
+            ["🏃‍♀️","woman running"],
+            ["💃","woman dancing"],
+            ["🕺","man dancing"],
+            ["🕴️","person in suit levitating"],
+            ["👯","people with bunny ears"],
+            ["👯‍♂️","men with bunny ears"],
+            ["👯‍♀️","women with bunny ears"],
+            ["🧖","person in steamy room"],
+            ["🧖‍♂️","man in steamy room"],
+            ["🧖‍♀️","woman in steamy room"],
+            ["🧗","person climbing"],
+            ["🧗‍♂️","man climbing"],
+            ["🧗‍♀️","woman climbing"],
+            ["🤺","person fencing"],
+            ["🏇","horse racing"],
+            ["⛷️","skier"],
+            ["🏂","snowboarder"],
+            ["🏌️","person golfing"],
+            ["🏌️‍♂️","man golfing"],
+            ["🏌️‍♀️","woman golfing"],
+            ["🏄","person surfing"],
+            ["🏄‍♂️","man surfing"],
+            ["🏄‍♀️","woman surfing"],
+            ["🚣","person rowing boat"],
+            ["🚣‍♂️","man rowing boat"],
+            ["🚣‍♀️","woman rowing boat"],
+            ["🏊","person swimming"],
+            ["🏊‍♂️","man swimming"],
+            ["🏊‍♀️","woman swimming"],
+            ["⛹️","person bouncing ball"],
+            ["⛹️‍♂️","man bouncing ball"],
+            ["⛹️‍♀️","woman bouncing ball"],
+            ["🏋️","person lifting weights"],
+            ["🏋️‍♂️","man lifting weights"],
+            ["🏋️‍♀️","woman lifting weights"],
+            ["🚴","person biking"],
+            ["🚴‍♂️","man biking"],
+            ["🚴‍♀️","woman biking"],
+            ["🚵","person mountain biking"],
+            ["🚵‍♂️","man mountain biking"],
+            ["🚵‍♀️","woman mountain biking"],
+            ["🤸","person cartwheeling"],
+            ["🤸‍♂️","man cartwheeling"],
+            ["🤸‍♀️","woman cartwheeling"],
+            ["🤼","people wrestling"],
+            ["🤼‍♂️","men wrestling"],
+            ["🤼‍♀️","women wrestling"],
+            ["🤽","person playing water polo"],
+            ["🤽‍♂️","man playing water polo"],
+            ["🤽‍♀️","woman playing water polo"],
+            ["🤾","person playing handball"],
+            ["🤾‍♂️","man playing handball"],
+            ["🤾‍♀️","woman playing handball"],
+            ["🤹","person juggling"],
+            ["🤹‍♂️","man juggling"],
+            ["🤹‍♀️","woman juggling"],
+            ["🧘","person in lotus position"],
+            ["🧘‍♂️","man in lotus position"],
+            ["🧘‍♀️","woman in lotus position"],
+            ["🛀","person taking bath"],
+            ["🛌","person in bed"],
+            ["🧑‍🤝‍🧑","people holding hands"],
+            ["👭","women holding hands"],
+            ["👫","woman and man holding hands"],
+            ["👬","men holding hands"],
+            ["💏","kiss"],
+            ["💑","couple with heart"],
+            ["👨‍👩‍👦","family man, woman, boy"],
+            ["👨‍👩‍👧","family man, woman, girl"],
+            ["👨‍👨‍👦","family man, man, boy"],
+            ["👨‍👨‍👧","family man, man, girl"],
+            ["👩‍👩‍👦","family woman, woman, boy"],
+            ["👩‍👩‍👧","family woman, woman, girl"],
+            ["👨‍👦","family man, boy"],
+            ["👨‍👦‍👦","family man, boy, boy"],
+            ["👨‍👧","family man, girl"],
+            ["👨‍👧‍👦","family man, girl, boy"],
+            ["👨‍👧‍👧","family man, girl, girl"],
+            ["👩‍👦","family woman, boy"],
+            ["👩‍👦‍👦","family woman, boy, boy"],
+            ["👩‍👧","family woman, girl"],
+            ["👩‍👧‍👦","family woman, girl, boy"],
+            ["👩‍👧‍👧","family woman, girl, girl"],
+            ["🗣️","speaking head"],
+            ["👤","bust in silhouette"],
+            ["👥","busts in silhouette"],
+            ["🫂","people hugging"],
+            ["👪","family"],
+            ["👣","footprints"],
+        ],
+    },
+    {
+        id: "animals_nature",
+        title: "Animals & Nature",
+        emojis: [
+            ["🐵","monkey face"],
+            ["🐒","monkey"],
+            ["🦍","gorilla"],
+            ["🦧","orangutan"],
+            ["🐶","dog face"],
+            ["🐕","dog"],
+            ["🦮","guide dog"],
+            ["🐕‍🦺","service dog"],
+            ["🐩","poodle"],
+            ["🐺","wolf"],
+            ["🦊","fox"],
+            ["🦝","raccoon"],
+            ["🐱","cat face"],
+            ["🐈","cat"],
+            ["🐈‍⬛","black cat"],
+            ["🦁","lion"],
+            ["🐯","tiger face"],
+            ["🐅","tiger"],
+            ["🐆","leopard"],
+            ["🐴","horse face"],
+            ["🫎","moose"],
+            ["🫏","donkey"],
+            ["🐎","horse"],
+            ["🦄","unicorn"],
+            ["🦓","zebra"],
+            ["🦌","deer"],
+            ["🦬","bison"],
+            ["🐮","cow face"],
+            ["🐂","ox"],
+            ["🐃","water buffalo"],
+            ["🐄","cow"],
+            ["🐷","pig face"],
+            ["🐖","pig"],
+            ["🐗","boar"],
+            ["🐽","pig nose"],
+            ["🐏","ram"],
+            ["🐑","ewe"],
+            ["🐐","goat"],
+            ["🐪","camel"],
+            ["🐫","two-hump camel"],
+            ["🦙","llama"],
+            ["🦒","giraffe"],
+            ["🐘","elephant"],
+            ["🦣","mammoth"],
+            ["🦏","rhinoceros"],
+            ["🦛","hippopotamus"],
+            ["🐭","mouse face"],
+            ["🐁","mouse"],
+            ["🐀","rat"],
+            ["🐹","hamster"],
+            ["🐰","rabbit face"],
+            ["🐇","rabbit"],
+            ["🐿️","chipmunk"],
+            ["🦫","beaver"],
+            ["🦔","hedgehog"],
+            ["🦇","bat"],
+            ["🐻","bear"],
+            ["🐻‍❄️","polar bear"],
+            ["🐨","koala"],
+            ["🐼","panda"],
+            ["🦥","sloth"],
+            ["🦦","otter"],
+            ["🦨","skunk"],
+            ["🦘","kangaroo"],
+            ["🦡","badger"],
+            ["🐾","paw prints"],
+            ["🦃","turkey"],
+            ["🐔","chicken"],
+            ["🐓","rooster"],
+            ["🐣","hatching chick"],
+            ["🐤","baby chick"],
+            ["🐥","front-facing baby chick"],
+            ["🐦","bird"],
+            ["🐧","penguin"],
+            ["🕊️","dove"],
+            ["🦅","eagle"],
+            ["🦆","duck"],
+            ["🦢","swan"],
+            ["🦉","owl"],
+            ["🦤","dodo"],
+            ["🪶","feather"],
+            ["🦩","flamingo"],
+            ["🦚","peacock"],
+            ["🦜","parrot"],
+            ["🪽","wing"],
+            ["🐦‍⬛","black bird"],
+            ["🪿","goose"],
+            ["🐸","frog"],
+            ["🐊","crocodile"],
+            ["🐢","turtle"],
+            ["🦎","lizard"],
+            ["🐍","snake"],
+            ["🐲","dragon face"],
+            ["🐉","dragon"],
+            ["🦕","sauropod"],
+            ["🦖","T-Rex"],
+            ["🐳","spouting whale"],
+            ["🐋","whale"],
+            ["🐬","dolphin"],
+            ["🦭","seal"],
+            ["🐟","fish"],
+            ["🐠","tropical fish"],
+            ["🐡","blowfish"],
+            ["🦈","shark"],
+            ["🐙","octopus"],
+            ["🐚","spiral shell"],
+            ["🪸","coral"],
+            ["🪼","jellyfish"],
+            ["🦀","crab"],
+            ["🦞","lobster"],
+            ["🦐","shrimp"],
+            ["🦑","squid"],
+            ["🦪","oyster"],
+            ["🐌","snail"],
+            ["🦋","butterfly"],
+            ["🐛","bug"],
+            ["🐜","ant"],
+            ["🐝","honeybee"],
+            ["🪲","beetle"],
+            ["🐞","lady beetle"],
+            ["🦗","cricket"],
+            ["🪳","cockroach"],
+            ["🕷️","spider"],
+            ["🕸️","spider web"],
+            ["🦂","scorpion"],
+            ["🦟","mosquito"],
+            ["🪰","fly"],
+            ["🪱","worm"],
+            ["🦠","microbe"],
+            ["💐","bouquet"],
+            ["🌸","cherry blossom"],
+            ["💮","white flower"],
+            ["🪷","lotus"],
+            ["🏵️","rosette"],
+            ["🌹","rose"],
+            ["🥀","wilted flower"],
+            ["🌺","hibiscus"],
+            ["🌻","sunflower"],
+            ["🌼","blossom"],
+            ["🌷","tulip"],
+            ["🪻","hyacinth"],
+            ["🌱","seedling"],
+            ["🪴","potted plant"],
+            ["🌲","evergreen tree"],
+            ["🌳","deciduous tree"],
+            ["🌴","palm tree"],
+            ["🌵","cactus"],
+            ["🌾","sheaf of rice"],
+            ["🌿","herb"],
+            ["☘️","shamrock"],
+            ["🍀","four leaf clover"],
+            ["🍁","maple leaf"],
+            ["🍂","fallen leaf"],
+            ["🍃","leaf fluttering in wind"],
+            ["🪹","empty nest"],
+            ["🪺","nest with eggs"],
+            ["🍄","mushroom"],
+        ],
+    },
+    {
+        id: "food_drink",
+        title: "Food & Drink",
+        emojis: [
+            ["🍇","grapes"],
+            ["🍈","melon"],
+            ["🍉","watermelon"],
+            ["🍊","tangerine"],
+            ["🍋","lemon"],
+            ["🍌","banana"],
+            ["🍍","pineapple"],
+            ["🥭","mango"],
+            ["🍎","red apple"],
+            ["🍏","green apple"],
+            ["🍐","pear"],
+            ["🍑","peach"],
+            ["🍒","cherries"],
+            ["🍓","strawberry"],
+            ["🫐","blueberries"],
+            ["🥝","kiwi fruit"],
+            ["🍅","tomato"],
+            ["🫒","olive"],
+            ["🥥","coconut"],
+            ["🥑","avocado"],
+            ["🍆","eggplant"],
+            ["🥔","potato"],
+            ["🥕","carrot"],
+            ["🌽","ear of corn"],
+            ["🌶️","hot pepper"],
+            ["🫑","bell pepper"],
+            ["🥒","cucumber"],
+            ["🥬","leafy green"],
+            ["🥦","broccoli"],
+            ["🧄","garlic"],
+            ["🧅","onion"],
+            ["🥜","peanuts"],
+            ["🫘","beans"],
+            ["🌰","chestnut"],
+            ["🫚","ginger root"],
+            ["🫛","pea pod"],
+            ["🍞","bread"],
+            ["🥐","croissant"],
+            ["🥖","baguette bread"],
+            ["🫓","flatbread"],
+            ["🥨","pretzel"],
+            ["🥯","bagel"],
+            ["🥞","pancakes"],
+            ["🧇","waffle"],
+            ["🧀","cheese wedge"],
+            ["🍖","meat on bone"],
+            ["🍗","poultry leg"],
+            ["🥩","cut of meat"],
+            ["🥓","bacon"],
+            ["🍔","hamburger"],
+            ["🍟","french fries"],
+            ["🍕","pizza"],
+            ["🌭","hot dog"],
+            ["🥪","sandwich"],
+            ["🌮","taco"],
+            ["🌯","burrito"],
+            ["🫔","tamale"],
+            ["🥙","stuffed flatbread"],
+            ["🧆","falafel"],
+            ["🥚","egg"],
+            ["🍳","cooking"],
+            ["🥘","shallow pan of food"],
+            ["🍲","pot of food"],
+            ["🫕","fondue"],
+            ["🥣","bowl with spoon"],
+            ["🥗","green salad"],
+            ["🍿","popcorn"],
+            ["🧈","butter"],
+            ["🧂","salt"],
+            ["🥫","canned food"],
+            ["🍱","bento box"],
+            ["🍘","rice cracker"],
+            ["🍙","rice ball"],
+            ["🍚","cooked rice"],
+            ["🍛","curry rice"],
+            ["🍜","steaming bowl"],
+            ["🍝","spaghetti"],
+            ["🍠","roasted sweet potato"],
+            ["🍢","oden"],
+            ["🍣","sushi"],
+            ["🍤","fried shrimp"],
+            ["🍥","fish cake with swirl"],
+            ["🥮","moon cake"],
+            ["🍡","dango"],
+            ["🥟","dumpling"],
+            ["🥠","fortune cookie"],
+            ["🥡","takeout box"],
+            ["🍦","soft ice cream"],
+            ["🍧","shaved ice"],
+            ["🍨","ice cream"],
+            ["🍩","doughnut"],
+            ["🍪","cookie"],
+            ["🎂","birthday cake"],
+            ["🍰","shortcake"],
+            ["🧁","cupcake"],
+            ["🥧","pie"],
+            ["🍫","chocolate bar"],
+            ["🍬","candy"],
+            ["🍭","lollipop"],
+            ["🍮","custard"],
+            ["🍯","honey pot"],
+            ["🍼","baby bottle"],
+            ["🥛","glass of milk"],
+            ["☕","hot beverage"],
+            ["🫖","teapot"],
+            ["🍵","teacup without handle"],
+            ["🍶","sake"],
+            ["🍾","bottle with popping cork"],
+            ["🍷","wine glass"],
+            ["🍸","cocktail glass"],
+            ["🍹","tropical drink"],
+            ["🍺","beer mug"],
+            ["🍻","clinking beer mugs"],
+            ["🥂","clinking glasses"],
+            ["🥃","tumbler glass"],
+            ["🫗","pouring liquid"],
+            ["🥤","cup with straw"],
+            ["🧋","bubble tea"],
+            ["🧃","beverage box"],
+            ["🧉","mate"],
+            ["🧊","ice"],
+            ["🥢","chopsticks"],
+            ["🍽️","fork and knife with plate"],
+            ["🍴","fork and knife"],
+            ["🥄","spoon"],
+            ["🔪","kitchen knife"],
+            ["🫙","jar"],
+            ["🏺","amphora"],
+        ],
+    },
+    {
+        id: "travel_places",
+        title: "Travel & Places",
+        emojis: [
+            ["🌍","globe showing Europe-Africa"],
+            ["🌎","globe showing Americas"],
+            ["🌏","globe showing Asia-Australia"],
+            ["🌐","globe with meridians"],
+            ["🗺️","world map"],
+            ["🗾","map of Japan"],
+            ["🧭","compass"],
+            ["🏔️","snow-capped mountain"],
+            ["⛰️","mountain"],
+            ["🌋","volcano"],
+            ["🗻","mount fuji"],
+            ["🏕️","camping"],
+            ["🏖️","beach with umbrella"],
+            ["🏜️","desert"],
+            ["🏝️","desert island"],
+            ["🏞️","national park"],
+            ["🏟️","stadium"],
+            ["🏛️","classical building"],
+            ["🏗️","building construction"],
+            ["🧱","brick"],
+            ["🪨","rock"],
+            ["🪵","wood"],
+            ["🛖","hut"],
+            ["🏘️","houses"],
+            ["🏚️","derelict house"],
+            ["🏠","house"],
+            ["🏡","house with garden"],
+            ["🏢","office building"],
+            ["🏣","Japanese post office"],
+            ["🏤","post office"],
+            ["🏥","hospital"],
+            ["🏦","bank"],
+            ["🏨","hotel"],
+            ["🏩","love hotel"],
+            ["🏪","convenience store"],
+            ["🏫","school"],
+            ["🏬","department store"],
+            ["🏭","factory"],
+            ["🏯","Japanese castle"],
+            ["🏰","castle"],
+            ["💒","wedding"],
+            ["🗼","Tokyo tower"],
+            ["🗽","Statue of Liberty"],
+            ["⛪","church"],
+            ["🕌","mosque"],
+            ["🛕","hindu temple"],
+            ["🕍","synagogue"],
+            ["⛩️","shinto shrine"],
+            ["🕋","kaaba"],
+            ["⛲","fountain"],
+            ["⛺","tent"],
+            ["🌁","foggy"],
+            ["🌃","night with stars"],
+            ["🏙️","cityscape"],
+            ["🌄","sunrise over mountains"],
+            ["🌅","sunrise"],
+            ["🌆","cityscape at dusk"],
+            ["🌇","sunset"],
+            ["🌉","bridge at night"],
+            ["♨️","hot springs"],
+            ["🎠","carousel horse"],
+            ["🛝","playground slide"],
+            ["🎡","ferris wheel"],
+            ["🎢","roller coaster"],
+            ["💈","barber pole"],
+            ["🎪","circus tent"],
+            ["🚂","locomotive"],
+            ["🚃","railway car"],
+            ["🚄","high-speed train"],
+            ["🚅","bullet train"],
+            ["🚆","train"],
+            ["🚇","metro"],
+            ["🚈","light rail"],
+            ["🚉","station"],
+            ["🚊","tram"],
+            ["🚝","monorail"],
+            ["🚞","mountain railway"],
+            ["🚋","tram car"],
+            ["🚌","bus"],
+            ["🚍","oncoming bus"],
+            ["🚎","trolleybus"],
+            ["🚐","minibus"],
+            ["🚑","ambulance"],
+            ["🚒","fire engine"],
+            ["🚓","police car"],
+            ["🚔","oncoming police car"],
+            ["🚕","taxi"],
+            ["🚖","oncoming taxi"],
+            ["🚗","automobile"],
+            ["🚘","oncoming automobile"],
+            ["🚙","sport utility vehicle"],
+            ["🛻","pickup truck"],
+            ["🚚","delivery truck"],
+            ["🚛","articulated lorry"],
+            ["🚜","tractor"],
+            ["🏎️","racing car"],
+            ["🏍️","motorcycle"],
+            ["🛵","motor scooter"],
+            ["🦽","manual wheelchair"],
+            ["🦼","motorized wheelchair"],
+            ["🛺","auto rickshaw"],
+            ["🚲","bicycle"],
+            ["🛴","kick scooter"],
+            ["🛹","skateboard"],
+            ["🛼","roller skate"],
+            ["🚏","bus stop"],
+            ["🛣️","motorway"],
+            ["🛤️","railway track"],
+            ["🛢️","oil drum"],
+            ["⛽","fuel pump"],
+            ["🛞","wheel"],
+            ["🚨","police car light"],
+            ["🚥","horizontal traffic light"],
+            ["🚦","vertical traffic light"],
+            ["🛑","stop sign"],
+            ["🚧","construction"],
+            ["⚓","anchor"],
+            ["🛟","ring buoy"],
+            ["⛵","sailboat"],
+            ["🛶","canoe"],
+            ["🚤","speedboat"],
+            ["🛳️","passenger ship"],
+            ["⛴️","ferry"],
+            ["🛥️","motor boat"],
+            ["🚢","ship"],
+            ["✈️","airplane"],
+            ["🛩️","small airplane"],
+            ["🛫","airplane departure"],
+            ["🛬","airplane arrival"],
+            ["🪂","parachute"],
+            ["💺","seat"],
+            ["🚁","helicopter"],
+            ["🚟","suspension railway"],
+            ["🚠","mountain cableway"],
+            ["🚡","aerial tramway"],
+            ["🛰️","satellite"],
+            ["🚀","rocket"],
+            ["🛸","flying saucer"],
+            ["🛎️","bellhop bell"],
+            ["🧳","luggage"],
+            ["⌛","hourglass done"],
+            ["⏳","hourglass not done"],
+            ["⌚","watch"],
+            ["⏰","alarm clock"],
+            ["⏱️","stopwatch"],
+            ["⏲️","timer clock"],
+            ["🕰️","mantelpiece clock"],
+            ["🕛","twelve o’clock"],
+            ["🕧","twelve-thirty"],
+            ["🕐","one o’clock"],
+            ["🕜","one-thirty"],
+            ["🕑","two o’clock"],
+            ["🕝","two-thirty"],
+            ["🕒","three o’clock"],
+            ["🕞","three-thirty"],
+            ["🕓","four o’clock"],
+            ["🕟","four-thirty"],
+            ["🕔","five o’clock"],
+            ["🕠","five-thirty"],
+            ["🕕","six o’clock"],
+            ["🕡","six-thirty"],
+            ["🕖","seven o’clock"],
+            ["🕢","seven-thirty"],
+            ["🕗","eight o’clock"],
+            ["🕣","eight-thirty"],
+            ["🕘","nine o’clock"],
+            ["🕤","nine-thirty"],
+            ["🕙","ten o’clock"],
+            ["🕥","ten-thirty"],
+            ["🕚","eleven o’clock"],
+            ["🕦","eleven-thirty"],
+            ["🌑","new moon"],
+            ["🌒","waxing crescent moon"],
+            ["🌓","first quarter moon"],
+            ["🌔","waxing gibbous moon"],
+            ["🌕","full moon"],
+            ["🌖","waning gibbous moon"],
+            ["🌗","last quarter moon"],
+            ["🌘","waning crescent moon"],
+            ["🌙","crescent moon"],
+            ["🌚","new moon face"],
+            ["🌛","first quarter moon face"],
+            ["🌜","last quarter moon face"],
+            ["🌡️","thermometer"],
+            ["☀️","sun"],
+            ["🌝","full moon face"],
+            ["🌞","sun with face"],
+            ["🪐","ringed planet"],
+            ["⭐","star"],
+            ["🌟","glowing star"],
+            ["🌠","shooting star"],
+            ["🌌","milky way"],
+            ["☁️","cloud"],
+            ["⛅","sun behind cloud"],
+            ["⛈️","cloud with lightning and rain"],
+            ["🌤️","sun behind small cloud"],
+            ["🌥️","sun behind large cloud"],
+            ["🌦️","sun behind rain cloud"],
+            ["🌧️","cloud with rain"],
+            ["🌨️","cloud with snow"],
+            ["🌩️","cloud with lightning"],
+            ["🌪️","tornado"],
+            ["🌫️","fog"],
+            ["🌬️","wind face"],
+            ["🌀","cyclone"],
+            ["🌈","rainbow"],
+            ["🌂","closed umbrella"],
+            ["☂️","umbrella"],
+            ["☔","umbrella with rain drops"],
+            ["⛱️","umbrella on ground"],
+            ["⚡","high voltage"],
+            ["❄️","snowflake"],
+            ["☃️","snowman"],
+            ["⛄","snowman without snow"],
+            ["☄️","comet"],
+            ["🔥","fire"],
+            ["💧","droplet"],
+            ["🌊","water wave"],
+        ],
+    },
+    {
+        id: "activities",
+        title: "Activities",
+        emojis: [
+            ["🎃","jack-o-lantern"],
+            ["🎄","Christmas tree"],
+            ["🎆","fireworks"],
+            ["🎇","sparkler"],
+            ["🧨","firecracker"],
+            ["✨","sparkles"],
+            ["🎈","balloon"],
+            ["🎉","party popper"],
+            ["🎊","confetti ball"],
+            ["🎋","tanabata tree"],
+            ["🎍","pine decoration"],
+            ["🎎","Japanese dolls"],
+            ["🎏","carp streamer"],
+            ["🎐","wind chime"],
+            ["🎑","moon viewing ceremony"],
+            ["🧧","red envelope"],
+            ["🎀","ribbon"],
+            ["🎁","wrapped gift"],
+            ["🎗️","reminder ribbon"],
+            ["🎟️","admission tickets"],
+            ["🎫","ticket"],
+            ["🎖️","military medal"],
+            ["🏆","trophy"],
+            ["🏅","sports medal"],
+            ["🥇","1st place medal"],
+            ["🥈","2nd place medal"],
+            ["🥉","3rd place medal"],
+            ["⚽","soccer ball"],
+            ["⚾","baseball"],
+            ["🥎","softball"],
+            ["🏀","basketball"],
+            ["🏐","volleyball"],
+            ["🏈","american football"],
+            ["🏉","rugby football"],
+            ["🎾","tennis"],
+            ["🥏","flying disc"],
+            ["🎳","bowling"],
+            ["🏏","cricket game"],
+            ["🏑","field hockey"],
+            ["🏒","ice hockey"],
+            ["🥍","lacrosse"],
+            ["🏓","ping pong"],
+            ["🏸","badminton"],
+            ["🥊","boxing glove"],
+            ["🥋","martial arts uniform"],
+            ["🥅","goal net"],
+            ["⛳","flag in hole"],
+            ["⛸️","ice skate"],
+            ["🎣","fishing pole"],
+            ["🤿","diving mask"],
+            ["🎽","running shirt"],
+            ["🎿","skis"],
+            ["🛷","sled"],
+            ["🥌","curling stone"],
+            ["🎯","bullseye"],
+            ["🪀","yo-yo"],
+            ["🪁","kite"],
+            ["🔫","water pistol"],
+            ["🎱","pool 8 ball"],
+            ["🔮","crystal ball"],
+            ["🪄","magic wand"],
+            ["🎮","video game"],
+            ["🕹️","joystick"],
+            ["🎰","slot machine"],
+            ["🎲","game die"],
+            ["🧩","puzzle piece"],
+            ["🧸","teddy bear"],
+            ["🪅","piñata"],
+            ["🪩","mirror ball"],
+            ["🪆","nesting dolls"],
+            ["♠️","spade suit"],
+            ["♥️","heart suit"],
+            ["♦️","diamond suit"],
+            ["♣️","club suit"],
+            ["♟️","chess pawn"],
+            ["🃏","joker"],
+            ["🀄","mahjong red dragon"],
+            ["🎴","flower playing cards"],
+            ["🎭","performing arts"],
+            ["🖼️","framed picture"],
+            ["🎨","artist palette"],
+            ["🧵","thread"],
+            ["🪡","sewing needle"],
+            ["🧶","yarn"],
+            ["🪢","knot"],
+        ],
+    },
+    {
+        id: "objects",
+        title: "Objects",
+        emojis: [
+            ["👓","glasses"],
+            ["🕶️","sunglasses"],
+            ["🥽","goggles"],
+            ["🥼","lab coat"],
+            ["🦺","safety vest"],
+            ["👔","necktie"],
+            ["👕","t-shirt"],
+            ["👖","jeans"],
+            ["🧣","scarf"],
+            ["🧤","gloves"],
+            ["🧥","coat"],
+            ["🧦","socks"],
+            ["👗","dress"],
+            ["👘","kimono"],
+            ["🥻","sari"],
+            ["🩱","one-piece swimsuit"],
+            ["🩲","briefs"],
+            ["🩳","shorts"],
+            ["👙","bikini"],
+            ["👚","woman’s clothes"],
+            ["🪭","folding hand fan"],
+            ["👛","purse"],
+            ["👜","handbag"],
+            ["👝","clutch bag"],
+            ["🛍️","shopping bags"],
+            ["🎒","backpack"],
+            ["🩴","thong sandal"],
+            ["👞","man’s shoe"],
+            ["👟","running shoe"],
+            ["🥾","hiking boot"],
+            ["🥿","flat shoe"],
+            ["👠","high-heeled shoe"],
+            ["👡","woman’s sandal"],
+            ["🩰","ballet shoes"],
+            ["👢","woman’s boot"],
+            ["🪮","hair pick"],
+            ["👑","crown"],
+            ["👒","woman’s hat"],
+            ["🎩","top hat"],
+            ["🎓","graduation cap"],
+            ["🧢","billed cap"],
+            ["🪖","military helmet"],
+            ["⛑️","rescue worker’s helmet"],
+            ["📿","prayer beads"],
+            ["💄","lipstick"],
+            ["💍","ring"],
+            ["💎","gem stone"],
+            ["🔇","muted speaker"],
+            ["🔈","speaker low volume"],
+            ["🔉","speaker medium volume"],
+            ["🔊","speaker high volume"],
+            ["📢","loudspeaker"],
+            ["📣","megaphone"],
+            ["📯","postal horn"],
+            ["🔔","bell"],
+            ["🔕","bell with slash"],
+            ["🎼","musical score"],
+            ["🎵","musical note"],
+            ["🎶","musical notes"],
+            ["🎙️","studio microphone"],
+            ["🎚️","level slider"],
+            ["🎛️","control knobs"],
+            ["🎤","microphone"],
+            ["🎧","headphone"],
+            ["📻","radio"],
+            ["🎷","saxophone"],
+            ["🎺","trumpet"],
+            ["🪗","accordion"],
+            ["🎸","guitar"],
+            ["🎹","musical keyboard"],
+            ["🎻","violin"],
+            ["🪕","banjo"],
+            ["🥁","drum"],
+            ["🪘","long drum"],
+            ["🪇","maracas"],
+            ["🪈","flute"],
+            ["📱","mobile phone"],
+            ["📲","mobile phone with arrow"],
+            ["☎️","telephone"],
+            ["📞","telephone receiver"],
+            ["📟","pager"],
+            ["📠","fax machine"],
+            ["🔋","battery"],
+            ["🪫","low battery"],
+            ["🔌","electric plug"],
+            ["💻","laptop"],
+            ["🖥️","desktop computer"],
+            ["🖨️","printer"],
+            ["⌨️","keyboard"],
+            ["🖱️","computer mouse"],
+            ["🖲️","trackball"],
+            ["💽","computer disk"],
+            ["💾","floppy disk"],
+            ["💿","optical disk"],
+            ["📀","dvd"],
+            ["🧮","abacus"],
+            ["🎥","movie camera"],
+            ["🎞️","film frames"],
+            ["📽️","film projector"],
+            ["🎬","clapper board"],
+            ["📺","television"],
+            ["📷","camera"],
+            ["📸","camera with flash"],
+            ["📹","video camera"],
+            ["📼","videocassette"],
+            ["🔍","magnifying glass tilted left"],
+            ["🔎","magnifying glass tilted right"],
+            ["🕯️","candle"],
+            ["💡","light bulb"],
+            ["🔦","flashlight"],
+            ["🏮","red paper lantern"],
+            ["🪔","diya lamp"],
+            ["📔","notebook with decorative cover"],
+            ["📕","closed book"],
+            ["📖","open book"],
+            ["📗","green book"],
+            ["📘","blue book"],
+            ["📙","orange book"],
+            ["📚","books"],
+            ["📓","notebook"],
+            ["📒","ledger"],
+            ["📃","page with curl"],
+            ["📜","scroll"],
+            ["📄","page facing up"],
+            ["📰","newspaper"],
+            ["🗞️","rolled-up newspaper"],
+            ["📑","bookmark tabs"],
+            ["🔖","bookmark"],
+            ["🏷️","label"],
+            ["🪙","coin"],
+            ["💰","money bag"],
+            ["💴","yen banknote"],
+            ["💵","dollar banknote"],
+            ["💶","euro banknote"],
+            ["💷","pound banknote"],
+            ["💸","money with wings"],
+            ["💳","credit card"],
+            ["🧾","receipt"],
+            ["💹","chart increasing with yen"],
+            ["✉️","envelope"],
+            ["📧","e-mail"],
+            ["📨","incoming envelope"],
+            ["📩","envelope with arrow"],
+            ["📤","outbox tray"],
+            ["📥","inbox tray"],
+            ["📦","package"],
+            ["📫","closed mailbox with raised flag"],
+            ["📪","closed mailbox with lowered flag"],
+            ["📬","open mailbox with raised flag"],
+            ["📭","open mailbox with lowered flag"],
+            ["📮","postbox"],
+            ["🗳️","ballot box with ballot"],
+            ["✏️","pencil"],
+            ["✒️","black nib"],
+            ["🖋️","fountain pen"],
+            ["🖊️","pen"],
+            ["🖌️","paintbrush"],
+            ["🖍️","crayon"],
+            ["📝","memo"],
+            ["💼","briefcase"],
+            ["📁","file folder"],
+            ["📂","open file folder"],
+            ["🗂️","card index dividers"],
+            ["📅","calendar"],
+            ["📆","tear-off calendar"],
+            ["🗒️","spiral notepad"],
+            ["🗓️","spiral calendar"],
+            ["📇","card index"],
+            ["📈","chart increasing"],
+            ["📉","chart decreasing"],
+            ["📊","bar chart"],
+            ["📋","clipboard"],
+            ["📌","pushpin"],
+            ["📍","round pushpin"],
+            ["📎","paperclip"],
+            ["🖇️","linked paperclips"],
+            ["📏","straight ruler"],
+            ["📐","triangular ruler"],
+            ["✂️","scissors"],
+            ["🗃️","card file box"],
+            ["🗄️","file cabinet"],
+            ["🗑️","wastebasket"],
+            ["🔒","locked"],
+            ["🔓","unlocked"],
+            ["🔏","locked with pen"],
+            ["🔐","locked with key"],
+            ["🔑","key"],
+            ["🗝️","old key"],
+            ["🔨","hammer"],
+            ["🪓","axe"],
+            ["⛏️","pick"],
+            ["⚒️","hammer and pick"],
+            ["🛠️","hammer and wrench"],
+            ["🗡️","dagger"],
+            ["⚔️","crossed swords"],
+            ["💣","bomb"],
+            ["🪃","boomerang"],
+            ["🏹","bow and arrow"],
+            ["🛡️","shield"],
+            ["🪚","carpentry saw"],
+            ["🔧","wrench"],
+            ["🪛","screwdriver"],
+            ["🔩","nut and bolt"],
+            ["⚙️","gear"],
+            ["🗜️","clamp"],
+            ["⚖️","balance scale"],
+            ["🦯","white cane"],
+            ["🔗","link"],
+            ["⛓️","chains"],
+            ["🪝","hook"],
+            ["🧰","toolbox"],
+            ["🧲","magnet"],
+            ["🪜","ladder"],
+            ["⚗️","alembic"],
+            ["🧪","test tube"],
+            ["🧫","petri dish"],
+            ["🧬","dna"],
+            ["🔬","microscope"],
+            ["🔭","telescope"],
+            ["📡","satellite antenna"],
+            ["💉","syringe"],
+            ["🩸","drop of blood"],
+            ["💊","pill"],
+            ["🩹","adhesive bandage"],
+            ["🩼","crutch"],
+            ["🩺","stethoscope"],
+            ["🩻","x-ray"],
+            ["🚪","door"],
+            ["🛗","elevator"],
+            ["🪞","mirror"],
+            ["🪟","window"],
+            ["🛏️","bed"],
+            ["🛋️","couch and lamp"],
+            ["🪑","chair"],
+            ["🚽","toilet"],
+            ["🪠","plunger"],
+            ["🚿","shower"],
+            ["🛁","bathtub"],
+            ["🪤","mouse trap"],
+            ["🪒","razor"],
+            ["🧴","lotion bottle"],
+            ["🧷","safety pin"],
+            ["🧹","broom"],
+            ["🧺","basket"],
+            ["🧻","roll of paper"],
+            ["🪣","bucket"],
+            ["🧼","soap"],
+            ["🫧","bubbles"],
+            ["🪥","toothbrush"],
+            ["🧽","sponge"],
+            ["🧯","fire extinguisher"],
+            ["🛒","shopping cart"],
+            ["🚬","cigarette"],
+            ["⚰️","coffin"],
+            ["🪦","headstone"],
+            ["⚱️","funeral urn"],
+            ["🧿","nazar amulet"],
+            ["🪬","hamsa"],
+            ["🗿","moai"],
+            ["🪧","placard"],
+            ["🪪","identification card"],
+        ],
+    },
+    {
+        id: "symbols",
+        title: "Symbols",
+        emojis: [
+            ["🏧","ATM sign"],
+            ["🚮","litter in bin sign"],
+            ["🚰","potable water"],
+            ["♿","wheelchair symbol"],
+            ["🚹","men’s room"],
+            ["🚺","women’s room"],
+            ["🚻","restroom"],
+            ["🚼","baby symbol"],
+            ["🚾","water closet"],
+            ["🛂","passport control"],
+            ["🛃","customs"],
+            ["🛄","baggage claim"],
+            ["🛅","left luggage"],
+            ["⚠️","warning"],
+            ["🚸","children crossing"],
+            ["⛔","no entry"],
+            ["🚫","prohibited"],
+            ["🚳","no bicycles"],
+            ["🚭","no smoking"],
+            ["🚯","no littering"],
+            ["🚱","non-potable water"],
+            ["🚷","no pedestrians"],
+            ["📵","no mobile phones"],
+            ["🔞","no one under eighteen"],
+            ["☢️","radioactive"],
+            ["☣️","biohazard"],
+            ["⬆️","up arrow"],
+            ["↗️","up-right arrow"],
+            ["➡️","right arrow"],
+            ["↘️","down-right arrow"],
+            ["⬇️","down arrow"],
+            ["↙️","down-left arrow"],
+            ["⬅️","left arrow"],
+            ["↖️","up-left arrow"],
+            ["↕️","up-down arrow"],
+            ["↔️","left-right arrow"],
+            ["↩️","right arrow curving left"],
+            ["↪️","left arrow curving right"],
+            ["⤴️","right arrow curving up"],
+            ["⤵️","right arrow curving down"],
+            ["🔃","clockwise vertical arrows"],
+            ["🔄","counterclockwise arrows button"],
+            ["🔙","BACK arrow"],
+            ["🔚","END arrow"],
+            ["🔛","ON! arrow"],
+            ["🔜","SOON arrow"],
+            ["🔝","TOP arrow"],
+            ["🛐","place of worship"],
+            ["⚛️","atom symbol"],
+            ["🕉️","om"],
+            ["✡️","star of David"],
+            ["☸️","wheel of dharma"],
+            ["☯️","yin yang"],
+            ["✝️","latin cross"],
+            ["☦️","orthodox cross"],
+            ["☪️","star and crescent"],
+            ["☮️","peace symbol"],
+            ["🕎","menorah"],
+            ["🔯","dotted six-pointed star"],
+            ["🪯","khanda"],
+            ["♈","Aries"],
+            ["♉","Taurus"],
+            ["♊","Gemini"],
+            ["♋","Cancer"],
+            ["♌","Leo"],
+            ["♍","Virgo"],
+            ["♎","Libra"],
+            ["♏","Scorpio"],
+            ["♐","Sagittarius"],
+            ["♑","Capricorn"],
+            ["♒","Aquarius"],
+            ["♓","Pisces"],
+            ["⛎","Ophiuchus"],
+            ["🔀","shuffle tracks button"],
+            ["🔁","repeat button"],
+            ["🔂","repeat single button"],
+            ["▶️","play button"],
+            ["⏩","fast-forward button"],
+            ["⏭️","next track button"],
+            ["⏯️","play or pause button"],
+            ["◀️","reverse button"],
+            ["⏪","fast reverse button"],
+            ["⏮️","last track button"],
+            ["🔼","upwards button"],
+            ["⏫","fast up button"],
+            ["🔽","downwards button"],
+            ["⏬","fast down button"],
+            ["⏸️","pause button"],
+            ["⏹️","stop button"],
+            ["⏺️","record button"],
+            ["⏏️","eject button"],
+            ["🎦","cinema"],
+            ["🔅","dim button"],
+            ["🔆","bright button"],
+            ["📶","antenna bars"],
+            ["🛜","wireless"],
+            ["📳","vibration mode"],
+            ["📴","mobile phone off"],
+            ["♀️","female sign"],
+            ["♂️","male sign"],
+            ["⚧️","transgender symbol"],
+            ["✖️","multiply"],
+            ["➕","plus"],
+            ["➖","minus"],
+            ["➗","divide"],
+            ["🟰","heavy equals sign"],
+            ["♾️","infinity"],
+            ["‼️","double exclamation mark"],
+            ["⁉️","exclamation question mark"],
+            ["❓","red question mark"],
+            ["❔","white question mark"],
+            ["❕","white exclamation mark"],
+            ["❗","red exclamation mark"],
+            ["〰️","wavy dash"],
+            ["💱","currency exchange"],
+            ["💲","heavy dollar sign"],
+            ["⚕️","medical symbol"],
+            ["♻️","recycling symbol"],
+            ["⚜️","fleur-de-lis"],
+            ["🔱","trident emblem"],
+            ["📛","name badge"],
+            ["🔰","Japanese symbol for beginner"],
+            ["⭕","hollow red circle"],
+            ["✅","check mark button"],
+            ["☑️","check box with check"],
+            ["✔️","check mark"],
+            ["❌","cross mark"],
+            ["❎","cross mark button"],
+            ["➰","curly loop"],
+            ["➿","double curly loop"],
+            ["〽️","part alternation mark"],
+            ["✳️","eight-spoked asterisk"],
+            ["✴️","eight-pointed star"],
+            ["❇️","sparkle"],
+            ["©️","copyright"],
+            ["®️","registered"],
+            ["™️","trade mark"],
+            ["#️⃣","keycap #"],
+            ["*️⃣","keycap *"],
+            ["0️⃣","keycap 0"],
+            ["1️⃣","keycap 1"],
+            ["2️⃣","keycap 2"],
+            ["3️⃣","keycap 3"],
+            ["4️⃣","keycap 4"],
+            ["5️⃣","keycap 5"],
+            ["6️⃣","keycap 6"],
+            ["7️⃣","keycap 7"],
+            ["8️⃣","keycap 8"],
+            ["9️⃣","keycap 9"],
+            ["🔟","keycap 10"],
+            ["🔠","input latin uppercase"],
+            ["🔡","input latin lowercase"],
+            ["🔢","input numbers"],
+            ["🔣","input symbols"],
+            ["🔤","input latin letters"],
+            ["🅰️","A button (blood type)"],
+            ["🆎","AB button (blood type)"],
+            ["🅱️","B button (blood type)"],
+            ["🆑","CL button"],
+            ["🆒","COOL button"],
+            ["🆓","FREE button"],
+            ["ℹ️","information"],
+            ["🆔","ID button"],
+            ["Ⓜ️","circled M"],
+            ["🆕","NEW button"],
+            ["🆖","NG button"],
+            ["🅾️","O button (blood type)"],
+            ["🆗","OK button"],
+            ["🅿️","P button"],
+            ["🆘","SOS button"],
+            ["🆙","UP! button"],
+            ["🆚","VS button"],
+            ["🈁","Japanese “here” button"],
+            ["🈂️","Japanese “service charge” button"],
+            ["🈷️","Japanese “monthly amount” button"],
+            ["🈶","Japanese “not free of charge” button"],
+            ["🈯","Japanese “reserved” button"],
+            ["🉐","Japanese “bargain” button"],
+            ["🈹","Japanese “discount” button"],
+            ["🈚","Japanese “free of charge” button"],
+            ["🈲","Japanese “prohibited” button"],
+            ["🉑","Japanese “acceptable” button"],
+            ["🈸","Japanese “application” button"],
+            ["🈴","Japanese “passing grade” button"],
+            ["🈳","Japanese “vacancy” button"],
+            ["㊗️","Japanese “congratulations” button"],
+            ["㊙️","Japanese “secret” button"],
+            ["🈺","Japanese “open for business” button"],
+            ["🈵","Japanese “no vacancy” button"],
+            ["🔴","red circle"],
+            ["🟠","orange circle"],
+            ["🟡","yellow circle"],
+            ["🟢","green circle"],
+            ["🔵","blue circle"],
+            ["🟣","purple circle"],
+            ["🟤","brown circle"],
+            ["⚫","black circle"],
+            ["⚪","white circle"],
+            ["🟥","red square"],
+            ["🟧","orange square"],
+            ["🟨","yellow square"],
+            ["🟩","green square"],
+            ["🟦","blue square"],
+            ["🟪","purple square"],
+            ["🟫","brown square"],
+            ["⬛","black large square"],
+            ["⬜","white large square"],
+            ["◼️","black medium square"],
+            ["◻️","white medium square"],
+            ["◾","black medium-small square"],
+            ["◽","white medium-small square"],
+            ["▪️","black small square"],
+            ["▫️","white small square"],
+            ["🔶","large orange diamond"],
+            ["🔷","large blue diamond"],
+            ["🔸","small orange diamond"],
+            ["🔹","small blue diamond"],
+            ["🔺","red triangle pointed up"],
+            ["🔻","red triangle pointed down"],
+            ["💠","diamond with a dot"],
+            ["🔘","radio button"],
+            ["🔳","white square button"],
+            ["🔲","black square button"],
+        ],
+    },
+    {
+        id: "flags",
+        title: "Flags",
+        emojis: [
+            ["🏁","chequered flag"],
+            ["🚩","triangular flag"],
+            ["🎌","crossed flags"],
+            ["🏴","black flag"],
+            ["🏳️","white flag"],
+            ["🏳️‍🌈","rainbow flag"],
+            ["🏳️‍⚧️","transgender flag"],
+            ["🏴‍☠️","pirate flag"],
+            ["🇦🇨","flag Ascension Island"],
+            ["🇦🇩","flag Andorra"],
+            ["🇦🇪","flag United Arab Emirates"],
+            ["🇦🇫","flag Afghanistan"],
+            ["🇦🇬","flag Antigua & Barbuda"],
+            ["🇦🇮","flag Anguilla"],
+            ["🇦🇱","flag Albania"],
+            ["🇦🇲","flag Armenia"],
+            ["🇦🇴","flag Angola"],
+            ["🇦🇶","flag Antarctica"],
+            ["🇦🇷","flag Argentina"],
+            ["🇦🇸","flag American Samoa"],
+            ["🇦🇹","flag Austria"],
+            ["🇦🇺","flag Australia"],
+            ["🇦🇼","flag Aruba"],
+            ["🇦🇽","flag Åland Islands"],
+            ["🇦🇿","flag Azerbaijan"],
+            ["🇧🇦","flag Bosnia & Herzegovina"],
+            ["🇧🇧","flag Barbados"],
+            ["🇧🇩","flag Bangladesh"],
+            ["🇧🇪","flag Belgium"],
+            ["🇧🇫","flag Burkina Faso"],
+            ["🇧🇬","flag Bulgaria"],
+            ["🇧🇭","flag Bahrain"],
+            ["🇧🇮","flag Burundi"],
+            ["🇧🇯","flag Benin"],
+            ["🇧🇱","flag St. Barthélemy"],
+            ["🇧🇲","flag Bermuda"],
+            ["🇧🇳","flag Brunei"],
+            ["🇧🇴","flag Bolivia"],
+            ["🇧🇶","flag Caribbean Netherlands"],
+            ["🇧🇷","flag Brazil"],
+            ["🇧🇸","flag Bahamas"],
+            ["🇧🇹","flag Bhutan"],
+            ["🇧🇻","flag Bouvet Island"],
+            ["🇧🇼","flag Botswana"],
+            ["🇧🇾","flag Belarus"],
+            ["🇧🇿","flag Belize"],
+            ["🇨🇦","flag Canada"],
+            ["🇨🇨","flag Cocos (Keeling) Islands"],
+            ["🇨🇩","flag Congo - Kinshasa"],
+            ["🇨🇫","flag Central African Republic"],
+            ["🇨🇬","flag Congo - Brazzaville"],
+            ["🇨🇭","flag Switzerland"],
+            ["🇨🇮","flag Côte d’Ivoire"],
+            ["🇨🇰","flag Cook Islands"],
+            ["🇨🇱","flag Chile"],
+            ["🇨🇲","flag Cameroon"],
+            ["🇨🇳","flag China"],
+            ["🇨🇴","flag Colombia"],
+            ["🇨🇵","flag Clipperton Island"],
+            ["🇨🇷","flag Costa Rica"],
+            ["🇨🇺","flag Cuba"],
+            ["🇨🇻","flag Cape Verde"],
+            ["🇨🇼","flag Curaçao"],
+            ["🇨🇽","flag Christmas Island"],
+            ["🇨🇾","flag Cyprus"],
+            ["🇨🇿","flag Czechia"],
+            ["🇩🇪","flag Germany"],
+            ["🇩🇬","flag Diego Garcia"],
+            ["🇩🇯","flag Djibouti"],
+            ["🇩🇰","flag Denmark"],
+            ["🇩🇲","flag Dominica"],
+            ["🇩🇴","flag Dominican Republic"],
+            ["🇩🇿","flag Algeria"],
+            ["🇪🇦","flag Ceuta & Melilla"],
+            ["🇪🇨","flag Ecuador"],
+            ["🇪🇪","flag Estonia"],
+            ["🇪🇬","flag Egypt"],
+            ["🇪🇭","flag Western Sahara"],
+            ["🇪🇷","flag Eritrea"],
+            ["🇪🇸","flag Spain"],
+            ["🇪🇹","flag Ethiopia"],
+            ["🇪🇺","flag European Union"],
+            ["🇫🇮","flag Finland"],
+            ["🇫🇯","flag Fiji"],
+            ["🇫🇰","flag Falkland Islands"],
+            ["🇫🇲","flag Micronesia"],
+            ["🇫🇴","flag Faroe Islands"],
+            ["🇫🇷","flag France"],
+            ["🇬🇦","flag Gabon"],
+            ["🇬🇧","flag United Kingdom"],
+            ["🇬🇩","flag Grenada"],
+            ["🇬🇪","flag Georgia"],
+            ["🇬🇫","flag French Guiana"],
+            ["🇬🇬","flag Guernsey"],
+            ["🇬🇭","flag Ghana"],
+            ["🇬🇮","flag Gibraltar"],
+            ["🇬🇱","flag Greenland"],
+            ["🇬🇲","flag Gambia"],
+            ["🇬🇳","flag Guinea"],
+            ["🇬🇵","flag Guadeloupe"],
+            ["🇬🇶","flag Equatorial Guinea"],
+            ["🇬🇷","flag Greece"],
+            ["🇬🇸","flag South Georgia & South Sandwich Islands"],
+            ["🇬🇹","flag Guatemala"],
+            ["🇬🇺","flag Guam"],
+            ["🇬🇼","flag Guinea-Bissau"],
+            ["🇬🇾","flag Guyana"],
+            ["🇭🇰","flag Hong Kong SAR China"],
+            ["🇭🇲","flag Heard & McDonald Islands"],
+            ["🇭🇳","flag Honduras"],
+            ["🇭🇷","flag Croatia"],
+            ["🇭🇹","flag Haiti"],
+            ["🇭🇺","flag Hungary"],
+            ["🇮🇨","flag Canary Islands"],
+            ["🇮🇩","flag Indonesia"],
+            ["🇮🇪","flag Ireland"],
+            ["🇮🇱","flag Israel"],
+            ["🇮🇲","flag Isle of Man"],
+            ["🇮🇳","flag India"],
+            ["🇮🇴","flag British Indian Ocean Territory"],
+            ["🇮🇶","flag Iraq"],
+            ["🇮🇷","flag Iran"],
+            ["🇮🇸","flag Iceland"],
+            ["🇮🇹","flag Italy"],
+            ["🇯🇪","flag Jersey"],
+            ["🇯🇲","flag Jamaica"],
+            ["🇯🇴","flag Jordan"],
+            ["🇯🇵","flag Japan"],
+            ["🇰🇪","flag Kenya"],
+            ["🇰🇬","flag Kyrgyzstan"],
+            ["🇰🇭","flag Cambodia"],
+            ["🇰🇮","flag Kiribati"],
+            ["🇰🇲","flag Comoros"],
+            ["🇰🇳","flag St. Kitts & Nevis"],
+            ["🇰🇵","flag North Korea"],
+            ["🇰🇷","flag South Korea"],
+            ["🇰🇼","flag Kuwait"],
+            ["🇰🇾","flag Cayman Islands"],
+            ["🇰🇿","flag Kazakhstan"],
+            ["🇱🇦","flag Laos"],
+            ["🇱🇧","flag Lebanon"],
+            ["🇱🇨","flag St. Lucia"],
+            ["🇱🇮","flag Liechtenstein"],
+            ["🇱🇰","flag Sri Lanka"],
+            ["🇱🇷","flag Liberia"],
+            ["🇱🇸","flag Lesotho"],
+            ["🇱🇹","flag Lithuania"],
+            ["🇱🇺","flag Luxembourg"],
+            ["🇱🇻","flag Latvia"],
+            ["🇱🇾","flag Libya"],
+            ["🇲🇦","flag Morocco"],
+            ["🇲🇨","flag Monaco"],
+            ["🇲🇩","flag Moldova"],
+            ["🇲🇪","flag Montenegro"],
+            ["🇲🇫","flag St. Martin"],
+            ["🇲🇬","flag Madagascar"],
+            ["🇲🇭","flag Marshall Islands"],
+            ["🇲🇰","flag North Macedonia"],
+            ["🇲🇱","flag Mali"],
+            ["🇲🇲","flag Myanmar (Burma)"],
+            ["🇲🇳","flag Mongolia"],
+            ["🇲🇴","flag Macao SAR China"],
+            ["🇲🇵","flag Northern Mariana Islands"],
+            ["🇲🇶","flag Martinique"],
+            ["🇲🇷","flag Mauritania"],
+            ["🇲🇸","flag Montserrat"],
+            ["🇲🇹","flag Malta"],
+            ["🇲🇺","flag Mauritius"],
+            ["🇲🇻","flag Maldives"],
+            ["🇲🇼","flag Malawi"],
+            ["🇲🇽","flag Mexico"],
+            ["🇲🇾","flag Malaysia"],
+            ["🇲🇿","flag Mozambique"],
+            ["🇳🇦","flag Namibia"],
+            ["🇳🇨","flag New Caledonia"],
+            ["🇳🇪","flag Niger"],
+            ["🇳🇫","flag Norfolk Island"],
+            ["🇳🇬","flag Nigeria"],
+            ["🇳🇮","flag Nicaragua"],
+            ["🇳🇱","flag Netherlands"],
+            ["🇳🇴","flag Norway"],
+            ["🇳🇵","flag Nepal"],
+            ["🇳🇷","flag Nauru"],
+            ["🇳🇺","flag Niue"],
+            ["🇳🇿","flag New Zealand"],
+            ["🇴🇲","flag Oman"],
+            ["🇵🇦","flag Panama"],
+            ["🇵🇪","flag Peru"],
+            ["🇵🇫","flag French Polynesia"],
+            ["🇵🇬","flag Papua New Guinea"],
+            ["🇵🇭","flag Philippines"],
+            ["🇵🇰","flag Pakistan"],
+            ["🇵🇱","flag Poland"],
+            ["🇵🇲","flag St. Pierre & Miquelon"],
+            ["🇵🇳","flag Pitcairn Islands"],
+            ["🇵🇷","flag Puerto Rico"],
+            ["🇵🇸","flag Palestinian Territories"],
+            ["🇵🇹","flag Portugal"],
+            ["🇵🇼","flag Palau"],
+            ["🇵🇾","flag Paraguay"],
+            ["🇶🇦","flag Qatar"],
+            ["🇷🇪","flag Réunion"],
+            ["🇷🇴","flag Romania"],
+            ["🇷🇸","flag Serbia"],
+            ["🇷🇺","flag Russia"],
+            ["🇷🇼","flag Rwanda"],
+            ["🇸🇦","flag Saudi Arabia"],
+            ["🇸🇧","flag Solomon Islands"],
+            ["🇸🇨","flag Seychelles"],
+            ["🇸🇩","flag Sudan"],
+            ["🇸🇪","flag Sweden"],
+            ["🇸🇬","flag Singapore"],
+            ["🇸🇭","flag St. Helena"],
+            ["🇸🇮","flag Slovenia"],
+            ["🇸🇯","flag Svalbard & Jan Mayen"],
+            ["🇸🇰","flag Slovakia"],
+            ["🇸🇱","flag Sierra Leone"],
+            ["🇸🇲","flag San Marino"],
+            ["🇸🇳","flag Senegal"],
+            ["🇸🇴","flag Somalia"],
+            ["🇸🇷","flag Suriname"],
+            ["🇸🇸","flag South Sudan"],
+            ["🇸🇹","flag São Tomé & Príncipe"],
+            ["🇸🇻","flag El Salvador"],
+            ["🇸🇽","flag Sint Maarten"],
+            ["🇸🇾","flag Syria"],
+            ["🇸🇿","flag Eswatini"],
+            ["🇹🇦","flag Tristan da Cunha"],
+            ["🇹🇨","flag Turks & Caicos Islands"],
+            ["🇹🇩","flag Chad"],
+            ["🇹🇫","flag French Southern Territories"],
+            ["🇹🇬","flag Togo"],
+            ["🇹🇭","flag Thailand"],
+            ["🇹🇯","flag Tajikistan"],
+            ["🇹🇰","flag Tokelau"],
+            ["🇹🇱","flag Timor-Leste"],
+            ["🇹🇲","flag Turkmenistan"],
+            ["🇹🇳","flag Tunisia"],
+            ["🇹🇴","flag Tonga"],
+            ["🇹🇷","flag Türkiye"],
+            ["🇹🇹","flag Trinidad & Tobago"],
+            ["🇹🇻","flag Tuvalu"],
+            ["🇹🇼","flag Taiwan"],
+            ["🇹🇿","flag Tanzania"],
+            ["🇺🇦","flag Ukraine"],
+            ["🇺🇬","flag Uganda"],
+            ["🇺🇲","flag U.S. Outlying Islands"],
+            ["🇺🇳","flag United Nations"],
+            ["🇺🇸","flag United States"],
+            ["🇺🇾","flag Uruguay"],
+            ["🇺🇿","flag Uzbekistan"],
+            ["🇻🇦","flag Vatican City"],
+            ["🇻🇨","flag St. Vincent & Grenadines"],
+            ["🇻🇪","flag Venezuela"],
+            ["🇻🇬","flag British Virgin Islands"],
+            ["🇻🇮","flag U.S. Virgin Islands"],
+            ["🇻🇳","flag Vietnam"],
+            ["🇻🇺","flag Vanuatu"],
+            ["🇼🇫","flag Wallis & Futuna"],
+            ["🇼🇸","flag Samoa"],
+            ["🇽🇰","flag Kosovo"],
+            ["🇾🇪","flag Yemen"],
+            ["🇾🇹","flag Mayotte"],
+            ["🇿🇦","flag South Africa"],
+            ["🇿🇲","flag Zambia"],
+            ["🇿🇼","flag Zimbabwe"],
+        ],
+    },
+];

+ 33 - 0
neo/packages/app/lib/emoji/reaction-emoji.ts

@@ -0,0 +1,33 @@
+/**
+ * Free-picked emoji reactions (UNA "Many" mode) are stored as `e_<hex code points>`,
+ * e.g. `e_1f600` for 😀 or `e_2764_fe0f` for ❤️. Same format as
+ * `BxDolVoteReactions::encodeEmoji()` on the UNA side.
+ */
+
+const CODE_RE = /^e(?:_[0-9a-f]{1,6})+$/;
+
+export function isEmojiReactionCode(name: string): boolean {
+    return CODE_RE.test(name);
+}
+
+/** The emoji for an `e_…` code, or null. */
+export function emojiFromReactionCode(name: string): string | null {
+    if (!isEmojiReactionCode(name)) return null;
+
+    const codes = name
+        .split('_')
+        .slice(1)
+        .map((hex) => parseInt(hex, 16));
+    if (codes.some((code) => code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))) return null;
+
+    return String.fromCodePoint(...codes);
+}
+
+export function reactionCodeFromEmoji(emoji: string): string {
+    return 'e_' + Array.from(emoji, (char) => (char.codePointAt(0) ?? 0).toString(16)).join('_');
+}
+
+/** Compare emoji without variation selectors (U+FE0E / U+FE0F). */
+export function emojiKey(emoji: string): string {
+    return emoji.replace(/[︎️]/g, '');
+}

+ 153 - 0
neo/packages/app/ui/molecules/emoji-picker/emoji-picker.tsx

@@ -0,0 +1,153 @@
+/**
+ * Emoji picker: search, a tab per group (the site's reactions first, then the
+ * Unicode groups) and a grid. Used by reactions in UNA's "Many" mode; the data
+ * is generated by scripts/generate-emoji-data.js.
+ */
+
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { View, Pressable, ScrollView, Row } from 'app/design/view';
+import { Text } from 'app/design/typography';
+import { InputWithIcons } from 'app/design/controls';
+import { cn, isWeb } from 'app/lib/util';
+import { EMOJI_GROUPS, type EmojiEntry } from 'app/lib/emoji/emoji-data';
+import { emojiKey } from 'app/lib/emoji/reaction-emoji';
+
+export type EmojiPickerItem = {
+    emoji: string;
+    title: string;
+};
+
+export type EmojiPickerProps = {
+    onSelect: (emoji: string) => void;
+    /** Emoji to show as chosen (the viewer's reactions). */
+    selected?: readonly string[];
+    /** Emoji that can't be chosen right now (e.g. the reactions limit is reached). */
+    isDisabled?: (emoji: string) => boolean;
+    /** Shown first, in their own tab: the site's reactions list. */
+    quick?: readonly EmojiPickerItem[];
+    /** A line under the grid, e.g. the reactions limit. */
+    note?: string;
+    className?: string;
+};
+
+const QUICK_TAB = 'quick';
+const SEARCH_LIMIT = 120;
+
+function searchEmoji(query: string): EmojiEntry[] {
+    const words = query.toLowerCase().split(/\s+/).filter(Boolean);
+    const found: EmojiEntry[] = [];
+    for (const group of EMOJI_GROUPS) {
+        for (const entry of group.emojis) {
+            if (words.every((word) => entry[1].includes(word))) found.push(entry);
+            if (found.length >= SEARCH_LIMIT) return found;
+        }
+    }
+    return found;
+}
+
+export default function EmojiPicker({
+    onSelect,
+    selected = [],
+    isDisabled,
+    quick = [],
+    note,
+    className,
+}: EmojiPickerProps) {
+    const { t } = useTranslation();
+    const [query, setQuery] = useState('');
+    const [tab, setTab] = useState<string>(quick.length ? QUICK_TAB : (EMOJI_GROUPS[0]?.id ?? ''));
+
+    const selectedKeys = new Set(selected.map(emojiKey));
+    const search = query.trim();
+
+    let entries: readonly EmojiEntry[];
+    if (search) entries = searchEmoji(search);
+    else if (tab === QUICK_TAB) entries = quick.map((item) => [item.emoji, item.title] as const);
+    else entries = EMOJI_GROUPS.find((group) => group.id === tab)?.emojis ?? [];
+
+    const tabs = [
+        ...(quick.length && quick[0] ? [{ id: QUICK_TAB, title: t('Reactions'), icon: quick[0].emoji }] : []),
+        ...EMOJI_GROUPS.map((group) => ({ id: group.id, title: t(group.title), icon: group.emojis[0]?.[0] ?? '' })),
+    ];
+
+    return (
+        <View className={cn('w-full gap-2', className)}>
+            <InputWithIcons
+                size="small"
+                rounded="full"
+                startDecorator="Search"
+                placeholder={t('Search emoji')}
+                aria-label={t('Search emoji')}
+                role="searchbox"
+                autoCapitalize="none"
+                autoCorrect={false}
+                value={query}
+                onChangeText={setQuery}
+            />
+
+            {/* The tabs stay while searching, so the picker keeps its size; a tab ends the search. */}
+            <Row className="justify-between" role="tablist">
+                {tabs.map((item) => {
+                    const active = !search && item.id === tab;
+                    return (
+                        <Pressable
+                            key={item.id}
+                            role="tab"
+                            aria-selected={active}
+                            accessibilityLabel={item.title}
+                            onPress={() => {
+                                setQuery('');
+                                setTab(item.id);
+                            }}
+                            className={cn(
+                                'w-8 h-8 items-center justify-center rounded-lg web:hover:bg-muted/60',
+                                active && 'bg-muted'
+                            )}
+                        >
+                            <Text className={cn('text-lg', !active && 'opacity-60')}>{item.icon}</Text>
+                        </Pressable>
+                    );
+                })}
+            </Row>
+
+            <ScrollView
+                className="h-60"
+                contentContainerClassName="flex-row flex-wrap"
+                // One tap picks an emoji while the search keyboard is open.
+                keyboardShouldPersistTaps="handled"
+            >
+                {entries.map(([emoji, name]) => {
+                    const chosen = selectedKeys.has(emojiKey(emoji));
+                    const disabled = !chosen && !!isDisabled?.(emoji);
+                    return (
+                        <Pressable
+                            key={emoji}
+                            accessibilityRole="button"
+                            accessibilityLabel={name}
+                            accessibilityState={{ selected: chosen, disabled }}
+                            // Web drops accessibilityState: say which emoji are chosen, as toggles.
+                            {...(isWeb ? { 'aria-pressed': chosen, 'aria-disabled': disabled } : {})}
+                            disabled={disabled}
+                            onPress={() => onSelect(emoji)}
+                            className={cn(
+                                'w-10 h-10 items-center justify-center rounded-lg',
+                                chosen ? 'bg-accent' : 'web:hover:bg-muted/60 web:active:bg-muted',
+                                disabled && 'opacity-40'
+                            )}
+                        >
+                            <Text className="text-2xl">{emoji}</Text>
+                        </Pressable>
+                    );
+                })}
+                {!entries.length ? (
+                    <Text className="w-full py-6 text-center text-sm text-muted-foreground">
+                        {t('No emoji found')}
+                    </Text>
+                ) : null}
+            </ScrollView>
+
+            {note ? <Text className="text-xs text-muted-foreground">{note}</Text> : null}
+        </View>
+    );
+}

+ 0 - 770
neo/packages/app/ui/molecules/objects/reactions.js

@@ -1,770 +0,0 @@
-/**
- * Reactions — action button + counter (compound / divided) + "who reacted" popup.
- *
- * Props (from backend / entity actions):
- *   type, system, object_id  — object id for API and socket
- *   action                   — button: title, reaction, is_voted, is_undo, is_disabled
- *   counter                  — { items: [{ name, count }, ...] }
- *   params                   — overrides for social_actions.reaction (+ button_*, show_*)
- *   displayType              — 'action' | 'counter' | 'both' (default both)
- *   primary                  — button variant=primary
- */
-
-import React, { useState, useRef, useEffect } from 'react';
-import {
-    Modal as ReactNativeModal,
-    TouchableWithoutFeedback,
-    Platform,
-    TouchableOpacity,
-} from 'react-native';
-import { useTranslation } from 'react-i18next';
-import { RemoveScroll } from 'react-remove-scroll';
-import { appSetting, FeedbackHaptics, isEmoji } from 'app/lib/util';
-import {
-    objectKey,
-    mergeState,
-    objectRequest,
-    resolveDisplayFlags,
-    buildButtonProps,
-    pickActionButton,
-    pickCounterButton,
-    resolveActionButtonFlags,
-    votedChannel,
-    useVotedSubscription,
-    profileUsersList,
-    PerformedByModal,
-    ActionMenuLayout,
-} from 'app/ui/molecules/objects/helpers';
-import { useCurrentUser } from 'app/context/user';
-import { useWindowSize } from 'app/context/measure';
-import { useSound } from 'app/lib/hooks/use-sound';
-import { Icon } from 'app/ui/atoms/icon';
-import Tooltip from 'app/ui/molecules/dialogs/tooltip';
-import { Text } from 'app/design/typography';
-import { View } from 'app/design/view';
-import { Modal, NeoButton } from 'app/design/controls';
-
-const isWeb = Platform.OS === 'web';
-
-/** Icon from iconset: web/native × svg/emoji */
-function resolveIcon(params, iconset, name) {
-    const platform = isWeb ? 'web' : 'native';
-    const iconType = name !== 'default' ? params['icon_type_' + platform] : 'svg';
-    return iconset?.[platform]?.[name]?.[iconType];
-}
-
-/** Live count for a reaction: prefer actionsData, else props.counter */
-function liveCount(item, actionsData) {
-    const key = 'count_' + item.name;
-    const fromState = actionsData?.counter?.[key];
-    if (fromState != undefined) return fromState;
-    return item.count;
-}
-
-// ---------------------------------------------------------------------------
-// Counter: divided — one button + modal per reaction
-// ---------------------------------------------------------------------------
-
-function buildCounterDivided({
-    t,
-    getIcon,
-    onOpenReaction,
-    actionsData,
-    performedBy,
-    popupVisible,
-    setPopupVisible,
-    showCombined,
-    params,
-    counter,
-    buttonProps,
-}) {
-    const showAsButton = params?.show_counter_as_button === true;
-    const CounterButton = pickCounterButton(showCombined, showAsButton);
-
-    const buttons = [];
-    const popups = [];
-
-    counter.items.forEach((item, index) => {
-        if (item.name == 'default') return;
-
-        const count = liveCount(item, actionsData);
-        if (!count) return;
-
-        buttons.push(
-            <CounterButton
-                {...buttonProps}
-                key={'counter-button-' + index}
-                startDecorator={getIcon(item.name)}
-                title={count}
-                onPress={(event) => onOpenReaction(item?.name || '', event)}
-            />
-        );
-
-        popups.push(
-            <PerformedByModal
-                key={'counter-popup-' + index}
-                title={t('Reactions')}
-                visible={popupVisible[item.name]}
-                onClose={() =>
-                    setPopupVisible((state) => ({ ...state, [item.name]: false }))
-                }
-                users={performedBy?.[item.name]}
-                listOptions={{ asRow: false, wrap: true }}
-            />
-        );
-    });
-
-    return [buttons, popups];
-}
-
-// ---------------------------------------------------------------------------
-// Counter: compound — total button + tabs inside modal
-// ---------------------------------------------------------------------------
-
-function buildCounterCompound({
-    t,
-    getIcon,
-    onOpen,
-    actionsData,
-    performedBy,
-    popupVisible,
-    setPopupVisible,
-    selectedTab,
-    setSelectedTab,
-    showCombined,
-    params,
-    counter,
-    buttonProps,
-}) {
-    let total = 0;
-    let selected = selectedTab;
-
-    const icons = Object.keys(counter.items).map((key) => {
-        const item = counter.items[key];
-        if (item.name == 'default') return;
-
-        const count = liveCount(item, actionsData);
-        if (!selected && count != 0) selected = item.name;
-        total += count;
-        if (!count) return;
-        return getIcon(item.name);
-    });
-
-    if (!total) return false;
-
-    const tabs = Object.keys(counter.items).map((key) => {
-        const item = counter.items[key];
-        if (item.name == 'default') return;
-        if (!performedBy?.[item.name]?.length) return;
-
-        let className = ' flex mx-1 flex-row w-min top-px';
-        if (item.name == selected) className += ' border-b-2 border-primary ';
-
-        return (
-            <View key={item.name} className={className}>
-                <NeoButton
-                    style="borderless"
-                    controlSize="small"
-                    borderShape="circle"
-                    image={getIcon(item.name)}
-                    accessibilityLabel={t('rvote_' + item.name + '_title')}
-                    onPress={() => setSelectedTab(item.name)}
-                />
-            </View>
-        );
-    });
-
-    const panels = Object.keys(counter.items).map((key) => {
-        const item = counter.items[key];
-        if (item.name == 'default') return;
-
-        if (!selected && item.count != 0) selected = item.name;
-
-        let className = '';
-        if (item.name != selected) className = 'hidden ';
-        className += 'gap-2 overflow-y-auto text-card-foreground';
-
-        return (
-            <View key={item.name} className={className}>
-                {profileUsersList(performedBy?.[item.name], {
-                    asRow: true,
-                    wrap: false,
-                })}
-            </View>
-        );
-    });
-
-    const showAsButton = params?.show_counter_as_button === true;
-    const CounterButton = pickCounterButton(showCombined, showAsButton);
-
-    return [
-        [
-            <CounterButton
-                {...buttonProps}
-                fullWidth={false}
-                key="counter"
-                startDecorator={icons}
-                title={total}
-                onPress={onOpen}
-            />,
-        ],
-        [
-            <Modal
-                key="counter-popup"
-                title={t('Reactions')}
-                onVisible={popupVisible}
-                onClose={() => setPopupVisible(false)}
-            >
-                <View className="relative flex-row border-b border-border/60  ">
-                    {tabs}
-                </View>
-                <View className="p-2">{panels}</View>
-            </Modal>,
-        ],
-    ];
-}
-
-// ---------------------------------------------------------------------------
-// Reaction picker popover (emoji bar next to button)
-//
-// Facebook-style: tap/click = default reaction (onDefault). The picker opens
-// on long press (native and touch web) or mouse hover (web); hovering out closes it.
-// ---------------------------------------------------------------------------
-
-const HOVER_OPEN_DELAY = 500;
-const HOVER_CLOSE_DELAY = 300;
-const HOVER_SLOP = 8;
-// Ignore the click that ends the long press which opened the picker.
-const OPEN_CLICK_GUARD = 500;
-
-function isPointInRect(x, y, rect, slop = 0) {
-    if (!rect) return false;
-    return (
-        x >= rect.left - slop &&
-        x <= rect.right + slop &&
-        y >= rect.top - slop &&
-        y <= rect.bottom + slop
-    );
-}
-
-function ReactionPopover({
-    items,
-    onTap,
-    onDefault,
-    disabled,
-    haptics,
-    children,
-    childRefProp = 'ref',
-}) {
-    const { width: windowWidth, height: windowHeight } = useWindowSize();
-    const [modalVisible, setModalVisible] = useState(false);
-    const [buttonPos, setButtonPos] = useState({
-        x: 0,
-        y: 0,
-        width: 0,
-        height: 0,
-    });
-    const buttonRef = useRef(null);
-    const barRef = useRef(null);
-    const openTimer = useRef(null);
-    const closeTimer = useRef(null);
-    const openedAt = useRef(0);
-
-    const clearTimers = () => {
-        clearTimeout(openTimer.current);
-        clearTimeout(closeTimer.current);
-        openTimer.current = null;
-        closeTimer.current = null;
-    };
-
-    useEffect(() => clearTimers, []);
-
-    const closeModal = () => {
-        clearTimers();
-        setModalVisible(false);
-    };
-
-    // RN host views have measureInWindow; the web View is a plain div, so fall
-    // back to its viewport rect (what RN-web's measureInWindow returns).
-    const measureButton = (callback) => {
-        const node = buttonRef.current;
-        if (typeof node?.measureInWindow === 'function') return node.measureInWindow(callback);
-        const rect = node?.getBoundingClientRect?.();
-        if (rect) callback(rect.left, rect.top, rect.width, rect.height);
-    };
-
-    const openModal = () => {
-        if (!buttonRef.current) return;
-
-        measureButton((x, y, width, height) => {
-            const popupHeight = 60;
-            const popoverWidth = 300;
-            const padding = 10;
-            let actY = isWeb ? y : y - 20;
-            if (actY + popupHeight >= windowHeight - 64) {
-                actY = y - popupHeight - height;
-            }
-
-            let adjustedX = x + width / 2 - popoverWidth / 2;
-            if (adjustedX < padding) adjustedX = padding;
-            if (adjustedX + popoverWidth > windowWidth - padding) {
-                adjustedX = windowWidth - popoverWidth - padding;
-            }
-
-            setButtonPos({ x: adjustedX, y: actY, width, height });
-            openedAt.current = Date.now();
-            setModalVisible(true);
-        });
-    };
-
-    const handleSelect = (item) => {
-        closeModal();
-        onTap?.(item);
-    };
-
-    const handlePress = (event) => {
-        clearTimers();
-        onDefault?.(event);
-    };
-
-    const handleLongPress = () => {
-        if (disabled) return;
-        FeedbackHaptics(haptics);
-        openModal();
-    };
-
-    // --- web hover (mouse only; touch opens the picker with a long press) ---
-
-    const handlePointerEnter = (event) => {
-        if (disabled || modalVisible || event.pointerType !== 'mouse') return;
-        clearTimeout(openTimer.current);
-        openTimer.current = setTimeout(openModal, HOVER_OPEN_DELAY);
-    };
-
-    const handlePointerLeave = () => {
-        clearTimeout(openTimer.current);
-        openTimer.current = null;
-    };
-
-    // The overlay covers the button while open, so hit-test manually: keep
-    // the bar open while the pointer is over the button or the bar.
-    const isOverTarget = (x, y) =>
-        isPointInRect(x, y, barRef.current?.getBoundingClientRect?.(), HOVER_SLOP) ||
-        isPointInRect(x, y, buttonRef.current?.getBoundingClientRect?.(), HOVER_SLOP);
-
-    const handleOverlayMouseMove = (event) => {
-        if (isOverTarget(event.clientX, event.clientY)) {
-            clearTimeout(closeTimer.current);
-            closeTimer.current = null;
-        } else if (!closeTimer.current) {
-            closeTimer.current = setTimeout(closeModal, HOVER_CLOSE_DELAY);
-        }
-    };
-
-    const handleOverlayClick = (event) => {
-        // The modal is a portal: keep clicks from reaching the message row.
-        event.stopPropagation();
-        if (Date.now() - openedAt.current < OPEN_CLICK_GUARD) return;
-        const { clientX, clientY } = event;
-        if (isPointInRect(clientX, clientY, barRef.current?.getBoundingClientRect?.())) return;
-        closeModal();
-        // A click on the (covered) button still means "like", as on Facebook.
-        if (isPointInRect(clientX, clientY, buttonRef.current?.getBoundingClientRect?.())) {
-            onDefault?.(event);
-        }
-    };
-
-    const reactionBar = (
-        <View
-            ref={barRef}
-            style={{
-                top: buttonPos.y + buttonPos.height + 5,
-                left: buttonPos.x,
-                elevation: 5,
-            }}
-            className=" absolute flex-row rounded-full shadow-card-outline dark:shadow-card-outline-deep p-1 px-1.5 bg-popover items-center h-14"
-        >
-            {items.map((item) => {
-                const content = isEmoji(item.icon) ? (
-                    <View className="w-11 items-center justify-center">
-                        <Text className="text-3xl  web:hover:scale-110 flex web:hover:bg-muted/50 web:active:bg-muted rounded-full">
-                            {item.icon}
-                        </Text>
-                    </View>
-                ) : (
-                    <View className="w-11 h-11  items-center text-secondary-foreground web:hover:text-foreground web:hover:scale-110 web:duration-200 justify-center flex web:hover:bg-muted/50 web:active:bg-muted rounded-full">
-                        <Icon icon={item.icon} size={24} />
-                    </View>
-                );
-
-                return (
-                    <TouchableOpacity
-                        key={item.id}
-                        onPress={() => handleSelect(item)}
-                    >
-                        {isWeb ? (
-                            <Tooltip content={item.title}>{content}</Tooltip>
-                        ) : (
-                            content
-                        )}
-                    </TouchableOpacity>
-                );
-            })}
-        </View>
-    );
-
-    const overlay = modalVisible ? (
-        <ReactNativeModal
-            presentationStyle="overFullScreen"
-            transparent={true}
-            visible={modalVisible}
-            onRequestClose={closeModal}
-        >
-            {isWeb ? (
-                <View
-                    className="flex-1 bg-transparent"
-                    onMouseMove={handleOverlayMouseMove}
-                    onClick={handleOverlayClick}
-                >
-                    <RemoveScroll>{reactionBar}</RemoveScroll>
-                </View>
-            ) : (
-                <TouchableWithoutFeedback onPress={closeModal}>
-                    <View className="flex-1 bg-transparent">{reactionBar}</View>
-                </TouchableWithoutFeedback>
-            )}
-        </ReactNativeModal>
-    ) : null;
-
-    const button =
-        children && typeof children === 'object'
-            ? React.cloneElement(children, {
-                  onPress: disabled ? undefined : handlePress,
-                  onLongPress: disabled ? undefined : handleLongPress,
-                  ...(isWeb ? {} : { [childRefProp]: buttonRef }),
-              })
-            : null;
-
-    if (isWeb) {
-        return (
-            <View
-                ref={buttonRef}
-                onPointerEnter={handlePointerEnter}
-                onPointerLeave={handlePointerLeave}
-                // Long press on touch opens the picker, not the browser menu
-                onContextMenu={(event) => event.preventDefault()}
-                className="select-none"
-            >
-                {button}
-                {overlay}
-            </View>
-        );
-    }
-
-    return (
-        <View>
-            {button}
-            {overlay}
-        </View>
-    );
-}
-
-// ---------------------------------------------------------------------------
-// Main
-// ---------------------------------------------------------------------------
-
-export default function ElementReactions(props) {
-    const { t } = useTranslation();
-    const { currentUser } = useCurrentUser();
-    const playSound = useSound('success');
-
-    const settings = appSetting('social_actions', 'reaction');
-    const params = { ...settings, ...props.params };
-    const action = props.action;
-    const counter = props.counter;
-
-    const key = objectKey(props.type, props.system, props.object_id);
-
-    const items = settings[props.system]?.items
-        ? settings[props.system].items
-        : params.items;
-    const iconset = settings[props.system]?.iconset
-        ? settings[props.system].iconset
-        : settings.iconset;
-
-    const flags = resolveDisplayFlags(params, props.displayType);
-    const showAction =
-        flags.showAction && !!action && !!action?.title;
-    const showCounter =
-        flags.showCounter && !!counter && !!counter?.items;
-    const showBoth = showAction && showCounter;
-    const showCombined =
-        showBoth &&
-        params?.show_combined != undefined &&
-        params.show_combined === true;
-
-    const buttonProps = buildButtonProps(props);
-
-    const [actionsData, setActionsData] = useState({});
-    const [performedBy, setPerformedBy] = useState();
-
-    // compound: one modal + selected tab
-    const [compoundPopupOpen, setCompoundPopupOpen] = useState(false);
-    const [compoundTab, setCompoundTab] = useState('');
-
-    // divided: modal per reaction
-    const initialDividedPopups = {};
-    for (const i in items) initialDividedPopups[items[i].name] = false;
-    const [dividedPopups, setDividedPopups] = useState(initialDividedPopups);
-
-    const allowViewVoted =
-        settings[props.system]?.allow_view_voted != undefined
-            ? settings[props.system].allow_view_voted
-            : true;
-
-    const getIcon = (name) => resolveIcon(params, iconset, name);
-
-    const object_params = {
-        service: 'TemplVoteServices',
-        system: props.system,
-        objectId: props.object_id,
-    };
-
-    // --- do / undo reaction ---
-
-    const doReaction = (reaction, event) => {
-        if (event) event.preventDefault();
-        FeedbackHaptics(params.haptics_type);
-
-        const optimistic = {
-            reaction,
-            title: params?.t?.[reaction] ?? reaction,
-        };
-        setActionsData((prev) => mergeState(prev, optimistic));
-
-        objectRequest(object_params, 'do', { value: 1, reaction }, (data) =>
-            setActionsData((prev) => mergeState(prev, data))
-        );
-        playSound();
-    };
-
-    const undoReaction = (event) => {
-        event.preventDefault();
-        const reaction =
-            actionsData?.reaction != undefined
-                ? actionsData.reaction
-                : props.action.reaction;
-
-        objectRequest(object_params, 'do', { value: 1, reaction }, (data) =>
-            setActionsData((prev) => mergeState(prev, data))
-        );
-        playSound();
-    };
-
-    // --- who reacted (compound / divided) ---
-
-    const openCompoundVoters = (event) => {
-        event.preventDefault();
-        if (!allowViewVoted) return;
-        FeedbackHaptics(params.haptics_type);
-
-        objectRequest(object_params, 'get_performed_by', {}, (data) => {
-            if (!data?.performed_by) return;
-            setPerformedBy(data.performed_by);
-            setCompoundTab('');
-            setCompoundPopupOpen(true);
-        });
-    };
-
-    const openDividedVoters = (reaction, event) => {
-        event.preventDefault();
-        if (!allowViewVoted || !reaction) return;
-        FeedbackHaptics(params.haptics_type);
-
-        objectRequest(object_params, 'get_performed_by', { reaction }, (data) => {
-            if (!data?.performed_by) return;
-            setPerformedBy(data.performed_by);
-            setDividedPopups((state) => ({ ...state, [reaction]: true }));
-        });
-    };
-
-    useVotedSubscription({
-        channel: votedChannel(props.system, props.type, props.object_id),
-        currentUserId: currentUser.id,
-        onUpdate: (next) => setActionsData((prev) => mergeState(prev, next)),
-    });
-
-    // --- action button ---
-
-    let actionButton;
-    const actionPopup = undefined;
-
-    if (showAction) {
-        const { showAsButton, showLabel } = resolveActionButtonFlags(params);
-        const canUndo = action?.is_undo === true;
-
-        let isVoted = action?.is_voted === true || false;
-        if (actionsData?.is_voted != undefined) {
-            isVoted = actionsData.is_voted === true;
-        }
-
-        let isDisabled = action?.is_disabled === true || false;
-        if (actionsData?.is_disabled != undefined) {
-            isDisabled = actionsData.is_disabled === true;
-        }
-
-        let reaction = action?.reaction || '';
-        if (actionsData?.reaction != undefined) {
-            reaction = actionsData.reaction;
-        }
-
-        let title = action?.title || '';
-        if (actionsData?.title != undefined) {
-            title = actionsData.title;
-        }
-
-        const ActionButton = pickActionButton(showCombined, showAsButton);
-
-        if (canUndo && isVoted) {
-            actionButton = (
-                <ActionButton
-                    key="action"
-                    startDecorator={getIcon(reaction)}
-                    title={showLabel ? title : ''}
-                    pressed={true}
-                    selectedState="voted"
-                    onPress={undoReaction}
-                    {...buttonProps}
-                />
-            );
-        } else {
-            const reactionItems = items.map((item) => ({
-                id: item.id,
-                name: item.name,
-                icon: getIcon(item.name),
-                title: t('rvote_' + item.name + '_title'),
-            }));
-
-            // Plain tap/click applies the default reaction ("like"), like Facebook.
-            const defaultReaction = (
-                items.find((item) => item.name === 'like') || items[0]
-            )?.name;
-
-            actionButton =
-                items.length > 1 ? (
-                    <ReactionPopover
-                        key="action"
-                        items={reactionItems}
-                        onTap={(item) => {
-                            doReaction(item.name);
-                        }}
-                        onDefault={() => doReaction(defaultReaction)}
-                        disabled={isDisabled}
-                        haptics={params.haptics_type}
-                        childRefProp="forwardedRef"
-                    >
-                        <ActionButton
-                            startDecorator={getIcon(reaction)}
-                            disabled={isDisabled}
-                            title={showLabel ? title : false}
-                            {...buttonProps}
-                        />
-                    </ReactionPopover>
-                ) : (
-                    <ActionButton
-                        key="action"
-                        startDecorator={getIcon(reaction)}
-                        title={showLabel ? title : false}
-                        onPress={() => {
-                            doReaction(items[0].name);
-                        }}
-                        disabled={isDisabled}
-                        {...buttonProps}
-                    />
-                );
-        }
-    }
-
-    // --- counter ---
-
-    const counterStyle = params?.show_counter_style || 'compound';
-    let counterParts = [];
-
-    if (showCounter && counter?.items != undefined) {
-        if (counterStyle === 'compound') {
-            counterParts = buildCounterCompound({
-                t,
-                getIcon,
-                onOpen: openCompoundVoters,
-                actionsData,
-                performedBy,
-                popupVisible: compoundPopupOpen,
-                setPopupVisible: setCompoundPopupOpen,
-                selectedTab: compoundTab,
-                setSelectedTab: setCompoundTab,
-                showCombined,
-                params,
-                counter,
-                buttonProps,
-            });
-        } else if (counterStyle === 'divided') {
-            counterParts = buildCounterDivided({
-                t,
-                getIcon,
-                onOpenReaction: openDividedVoters,
-                actionsData,
-                performedBy,
-                popupVisible: dividedPopups,
-                setPopupVisible: setDividedPopups,
-                showCombined,
-                params,
-                counter,
-                buttonProps,
-            });
-        }
-    }
-
-    // --- layout ---
-
-    const hasCounterButtons = showCounter && !!counterParts;
-
-    const combinedGroup = [];
-    if (showAction) combinedGroup.push(actionButton);
-    if (counterParts?.[0]) counterParts[0].forEach((item) => combinedGroup.push(item));
-
-    return (
-        <ActionMenuLayout
-            combined={showCombined}
-            buttonProps={buttonProps}
-            itemKey={key}
-            showAction={showAction}
-            showCounter={showCounter}
-            showBoth={showBoth}
-            params={{
-                ...props.params,
-                counter_flex: 'flex-auto flex-row gap-x-1',
-            }}
-            actionSlots={[
-                {
-                    element: actionButton,
-                    key: key + '-action-button',
-                    marginClass: showBoth && hasCounterButtons ? ' mr-2' : '',
-                },
-            ]}
-            counterButton={counterParts?.[0]}
-            counterPopup={counterParts?.[1]}
-            combinedGroup={combinedGroup}
-            extraPopups={
-                showAction && !!actionPopup ? (
-                    <View key={key + '-action-popup'}>{actionPopup}</View>
-                ) : null
-            }
-            emptyCheck={
-                !((showAction && !!actionButton) ||
-                  (showAction && !!actionPopup) ||
-                  hasCounterButtons ||
-                  (showCounter && !!counterParts))
-            }
-        />
-    );
-}

+ 1231 - 0
neo/packages/app/ui/molecules/objects/reactions.tsx

@@ -0,0 +1,1231 @@
+/**
+ * Reactions — action button + counter (compound / divided) + "who reacted" popup.
+ *
+ * Props (from backend / entity actions):
+ *   type, system, object_id  — object id for API and socket
+ *   action                   — button: title, reaction, reactions, is_voted, is_undo, is_disabled
+ *   counter                  — { type, items: [{ name, count, emoji?, title? }, ...] }
+ *   options                  — UNA reactions settings: mode, default, per_user, split_counters, items
+ *   params                   — overrides for social_actions.reaction (+ button_*, show_*)
+ *   displayType              — 'action' | 'counter' | 'both' (default both)
+ *   primary                  — button variant=primary
+ *
+ * Modes (UNA Studio > Settings > Global > Reactions):
+ *   solo — one reaction, the default one; a click gives or takes it back.
+ *   few  — the reactions list in a bar next to the button.
+ *   many — the list plus any emoji, in an emoji picker.
+ * With a default reaction a click gives it (or takes back the reaction the
+ * button shows) and the choice opens on hover / long press. Without one the
+ * button is a smile-plus that always opens the choice. A backend without
+ * `options` behaves as before: few, with "like" as the default.
+ */
+
+import React, { useState, useRef, useEffect, createContext, useContext, type ComponentType, type ReactNode } from 'react';
+import {
+    Modal as ReactNativeModal,
+    Platform,
+    TouchableOpacity,
+} from 'react-native';
+import { useTranslation } from 'react-i18next';
+import { RemoveScroll } from 'react-remove-scroll';
+import { appSetting, FeedbackHaptics, isEmoji, cn } from 'app/lib/util';
+import {
+    objectKey,
+    mergeState,
+    objectRequest,
+    resolveDisplayFlags,
+    buildButtonProps,
+    pickActionButton,
+    pickCounterButton,
+    resolveActionButtonFlags,
+    votedChannel,
+    useVotedSubscription,
+    profileUsersList,
+    PerformedByModal as PerformedByModalJs,
+    ActionMenuLayout as ActionMenuLayoutJs,
+} from 'app/ui/molecules/objects/helpers';
+import { useCurrentUser } from 'app/context/user';
+import { useWindowSize } from 'app/context/measure';
+import { useSound } from 'app/lib/hooks/use-sound';
+import { lazyComponent } from 'app/lib/lazy-component';
+import {
+    emojiFromReactionCode,
+    reactionCodeFromEmoji,
+    emojiKey,
+} from 'app/lib/emoji/reaction-emoji';
+import { Icon } from 'app/ui/atoms/icon';
+import TooltipJs from 'app/ui/molecules/dialogs/tooltip';
+import { Text } from 'app/design/typography';
+import { View, Pressable } from 'app/design/view';
+import { Modal, NeoButton } from 'app/design/controls';
+
+// The picker carries ~70 KB of emoji data: load it when it is first opened.
+const EmojiPicker = lazyComponent(() => import('app/ui/molecules/emoji-picker/emoji-picker'), {
+    name: 'EmojiPicker',
+});
+
+// JS components: their props are inferred from destructuring, so every prop reads as required.
+const PerformedByModal: ComponentType<any> = PerformedByModalJs;
+const ActionMenuLayout: ComponentType<any> = ActionMenuLayoutJs;
+const Tooltip: ComponentType<any> = TooltipJs;
+
+const isWeb = Platform.OS === 'web';
+
+type ReactionMode = 'solo' | 'few' | 'many';
+
+type ReactionItem = {
+    name: string;
+    title: string;
+    emoji: string;
+};
+
+type ReactionOptions = {
+    mode?: string;
+    default?: string;
+    per_user?: number;
+    split_counters?: boolean;
+    items?: ReactionItem[];
+};
+
+type CounterItem = {
+    name: string;
+    count: number;
+    emoji?: string;
+    title?: string;
+};
+
+type ReactionAction = {
+    is_undo?: boolean;
+    is_voted?: boolean;
+    is_disabled?: boolean;
+    reaction?: string;
+    reactions?: string[];
+    default?: string;
+    title?: string;
+};
+
+/** Live state from the vote response and the socket; overrides the props. */
+type ReactionState = {
+    is_voted?: boolean;
+    is_disabled?: boolean;
+    reaction?: string;
+    reactions?: string[];
+    title?: string;
+    counter?: Record<string, number | string>;
+};
+
+type ReactionConfig = {
+    mode: ReactionMode;
+    defaultName: string;
+    perUser: number;
+    split: boolean;
+    items: ReactionItem[];
+};
+
+type ReactionsProps = {
+    type: string;
+    system: string;
+    object_id: string | number;
+    action?: ReactionAction;
+    counter?: { type?: string; items?: CounterItem[] };
+    options?: ReactionOptions;
+    params?: Record<string, any>;
+    displayType?: string;
+    primary?: boolean;
+    [key: string]: any;
+};
+
+const MODES: readonly ReactionMode[] = ['solo', 'few', 'many'];
+const PER_USER_MAX = 5;
+const SMILE_PLUS = 'SmilePlus';
+
+// ---------------------------------------------------------------------------
+// Config
+// ---------------------------------------------------------------------------
+
+function resolveConfig(
+    options: ReactionOptions | undefined,
+    settingsItems: { name: string }[],
+    params: Record<string, any>,
+    iconset: any,
+    t: (key: string) => string
+): ReactionConfig {
+    let config: ReactionConfig;
+
+    if (options && Array.isArray(options.items)) {
+        const items = options.items.filter((item) => !!item?.name);
+        const mode = MODES.includes(options.mode as ReactionMode) ? (options.mode as ReactionMode) : 'few';
+        const defaultName =
+            options.default && items.some((item) => item.name === options.default) ? options.default : '';
+        config = {
+            mode,
+            defaultName,
+            perUser: Math.max(1, Math.min(PER_USER_MAX, Number(options.per_user) || 1)),
+            split: options.split_counters === true,
+            items,
+        };
+    } else {
+        // Backend without reactions settings: the list from NEO settings, "like" by default.
+        const items = settingsItems.map((item) => ({
+            name: item.name,
+            title: t('rvote_' + item.name + '_title'),
+            emoji: iconset?.native?.[item.name]?.emoji ?? '',
+        }));
+        config = {
+            mode: 'few',
+            defaultName: (items.find((item) => item.name === 'like') ?? items[0])?.name ?? '',
+            perUser: 1,
+            split: params?.show_counter_style === 'divided',
+            items,
+        };
+    }
+
+    if (config.mode === 'solo') {
+        const solo = config.items.find((item) => item.name === config.defaultName) ?? config.items[0];
+        config.items = solo ? [solo] : [];
+        config.defaultName = solo?.name ?? '';
+        config.perUser = 1;
+    }
+
+    return config;
+}
+
+// ---------------------------------------------------------------------------
+// Counter: divided — one pill + modal per reaction
+// ---------------------------------------------------------------------------
+
+function buildCounterDivided({
+    t,
+    getIcon,
+    getTitle,
+    onOpenReaction,
+    liveCount,
+    items,
+    mine,
+    performedBy,
+    popupVisible,
+    setPopupVisible,
+    showCombined,
+    params,
+    buttonProps,
+}: {
+    t: (key: string) => string;
+    getIcon: (name: string) => string;
+    getTitle: (name: string) => string;
+    onOpenReaction: (name: string, event: any) => void;
+    liveCount: (item: CounterItem) => number;
+    items: CounterItem[];
+    mine: string[];
+    performedBy: Record<string, any[]> | undefined;
+    popupVisible: Record<string, boolean>;
+    setPopupVisible: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
+    showCombined: boolean;
+    params: Record<string, any>;
+    buttonProps: Record<string, any>;
+}): [ReactNode[], ReactNode[]] {
+    const showAsButton = params?.show_counter_as_button === true;
+    const CounterButton = pickCounterButton(showCombined, showAsButton);
+
+    const buttons: ReactNode[] = [];
+    const popups: ReactNode[] = [];
+
+    items.forEach((item) => {
+        const count = liveCount(item);
+        if (!count) return;
+
+        const isMine = mine.includes(item.name);
+        buttons.push(
+            <CounterButton
+                {...buttonProps}
+                key={'counter-button-' + item.name}
+                startDecorator={getIcon(item.name)}
+                title={count}
+                // The count alone is no name; NeoButton would fall back to the icon's (Heart reads "Like").
+                accessibilityLabel={getSpokenName(item.name, getTitle) + ' ' + count}
+                pressed={isMine}
+                selectedState={isMine ? 'voted' : undefined}
+                onPress={(event: any) => onOpenReaction(item.name, event)}
+            />
+        );
+
+        popups.push(
+            <PerformedByModal
+                key={'counter-popup-' + item.name}
+                title={t('Reactions')}
+                visible={!!popupVisible[item.name]}
+                onClose={() => setPopupVisible((state) => ({ ...state, [item.name]: false }))}
+                users={performedBy?.[item.name]}
+                listOptions={{ asRow: false, wrap: true }}
+            />
+        );
+    });
+
+    return [buttons, popups];
+}
+
+// ---------------------------------------------------------------------------
+// Counter: compound — total button + tabs inside modal
+// ---------------------------------------------------------------------------
+
+function buildCounterCompound({
+    t,
+    getIcon,
+    getTitle,
+    onOpen,
+    liveCount,
+    items,
+    performedBy,
+    popupVisible,
+    setPopupVisible,
+    selectedTab,
+    setSelectedTab,
+    showCombined,
+    params,
+    buttonProps,
+}: {
+    t: (key: string) => string;
+    getIcon: (name: string) => string;
+    getTitle: (name: string) => string;
+    onOpen: (event: any) => void;
+    liveCount: (item: CounterItem) => number;
+    items: CounterItem[];
+    performedBy: Record<string, any[]> | undefined;
+    popupVisible: boolean;
+    setPopupVisible: (visible: boolean) => void;
+    selectedTab: string;
+    setSelectedTab: (name: string) => void;
+    showCombined: boolean;
+    params: Record<string, any>;
+    buttonProps: Record<string, any>;
+}): [ReactNode[], ReactNode[]] | false {
+    let total = 0;
+    let selected = selectedTab;
+
+    const icons = items.map((item) => {
+        const count = liveCount(item);
+        if (!selected && count != 0) selected = item.name;
+        total += count;
+        if (!count) return null;
+        return getIcon(item.name);
+    });
+
+    if (!total) return false;
+
+    const tabs = items.map((item) => {
+        if (!performedBy?.[item.name]?.length) return null;
+
+        return (
+            <View
+                key={item.name}
+                className={cn('flex mx-1 flex-row w-min top-px', item.name == selected && 'border-b-2 border-primary')}
+            >
+                <NeoButton
+                    style="borderless"
+                    controlSize="small"
+                    borderShape="circle"
+                    image={getIcon(item.name)}
+                    accessibilityLabel={getSpokenName(item.name, getTitle)}
+                    onPress={() => setSelectedTab(item.name)}
+                />
+            </View>
+        );
+    });
+
+    const panels = items.map((item) => (
+        <View
+            key={item.name}
+            className={cn('gap-2 overflow-y-auto text-card-foreground', item.name != selected && 'hidden')}
+        >
+            {profileUsersList(performedBy?.[item.name], { asRow: true, wrap: false })}
+        </View>
+    ));
+
+    const showAsButton = params?.show_counter_as_button === true;
+    const CounterButton = pickCounterButton(showCombined, showAsButton);
+
+    return [
+        [
+            <CounterButton
+                {...buttonProps}
+                fullWidth={false}
+                key="counter"
+                startDecorator={icons}
+                title={total}
+                accessibilityLabel={t('Reactions') + ' ' + total}
+                onPress={onOpen}
+            />,
+        ],
+        [
+            <Modal
+                key="counter-popup"
+                title={t('Reactions')}
+                onVisible={popupVisible}
+                onClose={() => setPopupVisible(false)}
+            >
+                <View className="relative flex-row border-b border-border/60">{tabs}</View>
+                <View className="p-2">{panels}</View>
+            </Modal>,
+        ],
+    ];
+}
+
+// ---------------------------------------------------------------------------
+// Popover: the reactions bar (few) or the emoji picker (many) next to the button.
+//
+// Opens on long press (native and touch web), on mouse hover (web, when
+// `hoverOpen`), or on click (`openOnPress`). Otherwise a click calls `onPress`.
+// ---------------------------------------------------------------------------
+
+const HOVER_OPEN_DELAY = 500;
+const HOVER_CLOSE_DELAY = 300;
+const HOVER_SLOP = 8;
+// Ignore the click that ends the long press which opened the popover.
+const OPEN_CLICK_GUARD = 500;
+const PANEL_GAP = 6;
+const SCREEN_PADDING = 10;
+
+type Rect = { left: number; right: number; top: number; bottom: number };
+type Box = { x: number; y: number; width: number; height: number };
+
+/** Closes the open popover; used by its panel after a choice. */
+const PopoverCloseContext = createContext<() => void>(() => {});
+
+// The action button and the counter of one object are separate instances. A
+// vote reaches the others at once, without waiting for (or needing) the socket.
+const voteListeners = new Map<string, Set<(next: ReactionState) => void>>();
+
+function shareVote(channel: string, next: ReactionState) {
+    voteListeners.get(channel)?.forEach((listener) => listener(next));
+}
+
+/** Screen readers name an emoji themselves; a picked one has no other title than "Reacted". */
+function getSpokenName(name: string, getTitle: (name: string) => string): string {
+    return emojiFromReactionCode(name) ?? getTitle(name);
+}
+
+function isPointInRect(x: number, y: number, rect: Rect | undefined, slop = 0) {
+    if (!rect) return false;
+    return x >= rect.left - slop && x <= rect.right + slop && y >= rect.top - slop && y <= rect.bottom + slop;
+}
+
+function ReactionPopover({
+    children: panel,
+    trigger,
+    panelWidth,
+    panelHeight,
+    onPress,
+    openOnPress = false,
+    hoverOpen = true,
+    hoverClose = true,
+    disabled,
+    haptics,
+    children,
+    childRefProp = 'ref',
+}: {
+    /** The panel: the reactions bar or the emoji picker. */
+    children: ReactNode;
+    /** The button that opens it. */
+    trigger: React.ReactElement<any>;
+    panelWidth: number;
+    panelHeight: number;
+    onPress?: (event?: any) => void;
+    openOnPress?: boolean;
+    hoverOpen?: boolean;
+    hoverClose?: boolean;
+    disabled?: boolean;
+    haptics?: string;
+    childRefProp?: string;
+}) {
+    const { t } = useTranslation();
+    const { width: windowWidth, height: windowHeight } = useWindowSize();
+    const [modalVisible, setModalVisible] = useState(false);
+    // The button and the overlay in window coordinates. Native measures the
+    // overlay once it is laid out, as it may not start at the window's top.
+    const [anchor, setAnchor] = useState<Box | null>(null);
+    const [nativeOverlay, setNativeOverlay] = useState<Box | null>(null);
+    const buttonRef = useRef<any>(null);
+    const panelRef = useRef<any>(null);
+    const overlayRef = useRef<any>(null);
+    const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+    const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+    const openedAt = useRef(0);
+    // Web keyboard: opened from the keyboard, focus goes into the panel and comes back to the button on close.
+    const openedByKeyboard = useRef(false);
+
+    const clearTimers = () => {
+        if (openTimer.current) clearTimeout(openTimer.current);
+        if (closeTimer.current) clearTimeout(closeTimer.current);
+        openTimer.current = null;
+        closeTimer.current = null;
+    };
+
+    useEffect(() => clearTimers, []);
+
+    /** The panel's controls a keyboard can reach: the picker's search field, then the reactions that can be chosen. */
+    const panelFocusables = (): HTMLElement[] =>
+        Array.from(panelRef.current?.querySelectorAll?.('input, [role="button"]:not([aria-disabled="true"])') ?? []);
+
+    const closeModal = () => {
+        clearTimers();
+        setModalVisible(false);
+        if (isWeb && openedByKeyboard.current) {
+            openedByKeyboard.current = false;
+            buttonRef.current?.querySelector?.('[role="button"], button')?.focus();
+        }
+    };
+
+    // RN host views have measureInWindow; the web View is a plain div, so fall
+    // back to its viewport rect (what RN-web's measureInWindow returns).
+    const measureButton = (callback: (x: number, y: number, width: number, height: number) => void) => {
+        const node = buttonRef.current;
+        if (typeof node?.measureInWindow === 'function') return node.measureInWindow(callback);
+        const rect = node?.getBoundingClientRect?.();
+        if (rect) callback(rect.left, rect.top, rect.width, rect.height);
+    };
+
+    const openModal = () => {
+        if (!buttonRef.current) return;
+
+        measureButton((x, y, width, height) => {
+            setAnchor({ x, y, width, height });
+            setNativeOverlay(null);
+            openedAt.current = Date.now();
+            setModalVisible(true);
+        });
+    };
+
+    const measureOverlay = () => {
+        overlayRef.current?.measureInWindow?.((x: number, y: number, width: number, height: number) =>
+            setNativeOverlay({ x, y, width, height })
+        );
+    };
+
+    // Below the button when it fits, else above it (anchored by its bottom
+    // edge, so the panel's real height doesn't matter), else at the top.
+    const overlay = isWeb ? { x: 0, y: 0, width: windowWidth, height: windowHeight } : nativeOverlay;
+    let panelStyle: { top?: number; bottom?: number; left: number } | null = null;
+    if (anchor && overlay) {
+        const buttonTop = anchor.y - overlay.y;
+        const below = buttonTop + anchor.height + PANEL_GAP;
+        const left = Math.max(
+            SCREEN_PADDING,
+            Math.min(
+                anchor.x - overlay.x + anchor.width / 2 - panelWidth / 2,
+                overlay.width - panelWidth - SCREEN_PADDING
+            )
+        );
+
+        if (below + panelHeight <= overlay.height - SCREEN_PADDING) panelStyle = { top: below, left };
+        else if (buttonTop - PANEL_GAP - panelHeight >= SCREEN_PADDING)
+            panelStyle = { bottom: overlay.height - buttonTop + PANEL_GAP, left };
+        else panelStyle = { top: SCREEN_PADDING, left };
+    }
+
+    const handlePress = (event?: any) => {
+        clearTimers();
+        if (openOnPress) openModal();
+        else onPress?.(event);
+    };
+
+    // --- web keyboard: the arrow keys open the panel (Enter keeps its click action) ---
+
+    const handleButtonKeyDown = (event: any) => {
+        if (disabled || modalVisible) return;
+        const opens = event.key === 'ArrowDown' || event.key === 'ArrowUp' || (openOnPress && (event.key === 'Enter' || event.key === ' '));
+        if (!opens) return;
+        openedByKeyboard.current = true;
+        if (event.key.startsWith('Arrow')) {
+            event.preventDefault();
+            clearTimers();
+            openModal();
+        }
+    };
+
+    // The arrow keys move between reactions; in the search field they move the caret.
+    const handlePanelKeyDown = (event: any) => {
+        const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key as string];
+        if (!step || event.target?.tagName === 'INPUT') return;
+        const list = panelFocusables();
+        const i = list.indexOf(event.target);
+        if (i < 0) return;
+        event.preventDefault();
+        list[(i + step + list.length) % list.length]?.focus();
+    };
+
+    // Opened from the keyboard: focus the reaction already given, else the first control. The emoji picker loads
+    // lazily, so on its first opening the panel can still be empty: wait for its controls to appear.
+    const panelShown = !!panelStyle;
+    useEffect(() => {
+        if (!isWeb || !modalVisible || !panelShown || !openedByKeyboard.current) return;
+        const focusInto = () => {
+            const list = panelFocusables();
+            const target = list.find((e) => e.getAttribute('aria-pressed') === 'true') ?? list[0];
+            target?.focus();
+            return !!target;
+        };
+        if (focusInto() || !panelRef.current) return;
+        const observer = new MutationObserver(() => {
+            if (focusInto()) observer.disconnect();
+        });
+        observer.observe(panelRef.current, { childList: true, subtree: true });
+        return () => observer.disconnect();
+    }, [modalVisible, panelShown]);
+
+    // Escape closes the panel however it was opened (hover and click leave focus outside it).
+    useEffect(() => {
+        if (!isWeb || !modalVisible) return;
+        const onKeyDown = (event: KeyboardEvent) => {
+            if (event.key !== 'Escape') return;
+            event.preventDefault();
+            closeModal();
+        };
+        window.addEventListener('keydown', onKeyDown);
+        return () => window.removeEventListener('keydown', onKeyDown);
+        // eslint-disable-next-line react-hooks/exhaustive-deps -- closeModal only clears timers and state
+    }, [modalVisible]);
+
+    const handleLongPress = () => {
+        if (disabled) return;
+        FeedbackHaptics(haptics);
+        openModal();
+    };
+
+    // --- web hover (mouse only; touch opens the popover with a long press) ---
+
+    const handlePointerEnter = (event: any) => {
+        if (!hoverOpen || disabled || modalVisible || event.pointerType !== 'mouse') return;
+        if (openTimer.current) clearTimeout(openTimer.current);
+        openTimer.current = setTimeout(openModal, HOVER_OPEN_DELAY);
+    };
+
+    const handlePointerLeave = () => {
+        if (openTimer.current) clearTimeout(openTimer.current);
+        openTimer.current = null;
+    };
+
+    // The overlay covers the button while open, so hit-test manually: keep
+    // the popover open while the pointer is over the button or the panel.
+    const isOverTarget = (x: number, y: number) =>
+        isPointInRect(x, y, panelRef.current?.getBoundingClientRect?.(), HOVER_SLOP) ||
+        isPointInRect(x, y, buttonRef.current?.getBoundingClientRect?.(), HOVER_SLOP);
+
+    const handleOverlayMouseMove = (event: any) => {
+        if (!hoverClose) return;
+        if (isOverTarget(event.clientX, event.clientY)) {
+            if (closeTimer.current) clearTimeout(closeTimer.current);
+            closeTimer.current = null;
+        } else if (!closeTimer.current) {
+            closeTimer.current = setTimeout(closeModal, HOVER_CLOSE_DELAY);
+        }
+    };
+
+    const handleOverlayClick = (event: any) => {
+        // The modal is a portal: keep clicks from reaching the message row.
+        event.stopPropagation();
+        if (Date.now() - openedAt.current < OPEN_CLICK_GUARD) return;
+        const { clientX, clientY } = event;
+        if (isPointInRect(clientX, clientY, panelRef.current?.getBoundingClientRect?.())) return;
+        closeModal();
+        // A click on the (covered) button still means its click action, as on Facebook.
+        if (!openOnPress && isPointInRect(clientX, clientY, buttonRef.current?.getBoundingClientRect?.())) {
+            onPress?.(event);
+        }
+    };
+
+    // Web-only DOM handlers; the design View's type only knows React Native props.
+    const overlayWebProps: Record<string, unknown> = {
+        onMouseMove: handleOverlayMouseMove,
+        onClick: handleOverlayClick,
+    };
+    const buttonWebProps: Record<string, unknown> = {
+        onPointerEnter: handlePointerEnter,
+        onPointerLeave: handlePointerLeave,
+        onKeyDown: handleButtonKeyDown,
+        // Long press on touch opens the popover, not the browser menu
+        onContextMenu: (event: any) => event.preventDefault(),
+    };
+
+    const panelWebProps: Record<string, unknown> = isWeb ? { onKeyDown: handlePanelKeyDown } : {};
+
+    const panelView = panelStyle ? (
+        <View
+            ref={panelRef}
+            style={{ ...panelStyle, width: panelWidth, elevation: 5 }}
+            className="absolute"
+            {...panelWebProps}
+            // Native: taps on the panel's own background must not reach the overlay, which closes it.
+            onStartShouldSetResponder={() => true}
+        >
+            <PopoverCloseContext.Provider value={closeModal}>{panel}</PopoverCloseContext.Provider>
+        </View>
+    ) : null;
+
+    const modal = modalVisible ? (
+        <ReactNativeModal
+            presentationStyle="overFullScreen"
+            transparent={true}
+            visible={modalVisible}
+            onRequestClose={closeModal}
+        >
+            {isWeb ? (
+                <View className="flex-1 bg-transparent" {...overlayWebProps}>
+                    <RemoveScroll>{panelView}</RemoveScroll>
+                </View>
+            ) : (
+                // The backdrop is a sibling of the panel: wrapping the panel in a
+                // touchable would merge its reactions into one accessibility element.
+                <View
+                    ref={overlayRef}
+                    className="flex-1 bg-transparent"
+                    accessibilityViewIsModal
+                    onLayout={measureOverlay}
+                >
+                    <Pressable
+                        className="absolute inset-0"
+                        accessibilityRole="button"
+                        accessibilityLabel={t('Close')}
+                        onPress={closeModal}
+                    />
+                    {panelView}
+                </View>
+            )}
+        </ReactNativeModal>
+    ) : null;
+
+    const button = React.cloneElement(trigger, {
+        onPress: disabled ? undefined : handlePress,
+        onLongPress: disabled ? undefined : handleLongPress,
+        ...(isWeb ? {} : { [childRefProp]: buttonRef }),
+    });
+
+    if (isWeb) {
+        return (
+            <View ref={buttonRef} className="select-none" {...buttonWebProps}>
+                {button}
+                {modal}
+            </View>
+        );
+    }
+
+    return (
+        <View>
+            {button}
+            {modal}
+        </View>
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Few mode: the reactions bar
+// ---------------------------------------------------------------------------
+
+const BAR_ITEM_WIDTH = 44;
+const BAR_PADDING = 12;
+const BAR_HEIGHT = 56;
+const PICKER_WIDTH = 336;
+const PICKER_HEIGHT = 380;
+
+function ReactionBar({
+    items,
+    getIcon,
+    mine,
+    atLimit,
+    note,
+    onSelect,
+}: {
+    items: ReactionItem[];
+    getIcon: (name: string) => string;
+    mine: string[];
+    atLimit: boolean;
+    note?: string;
+    onSelect: (name: string) => void;
+}) {
+    const close = useContext(PopoverCloseContext);
+
+    return (
+        <View className="items-center gap-1">
+            <View className="flex-row rounded-full shadow-card-outline dark:shadow-card-outline-deep p-1 px-1.5 bg-popover items-center h-14">
+                {items.map((item) => {
+                    const icon = getIcon(item.name);
+                    const chosen = mine.includes(item.name);
+                    const disabled = atLimit && !chosen;
+                    const content = (
+                        <View
+                            className={cn(
+                                'w-11 h-11 items-center justify-center rounded-full',
+                                chosen ? 'bg-accent' : 'web:hover:bg-muted/50 web:active:bg-muted web:hover:scale-110 web:duration-200',
+                                disabled && 'opacity-40'
+                            )}
+                        >
+                            {isEmoji(icon) ? (
+                                <Text className="text-3xl">{icon}</Text>
+                            ) : (
+                                <View className="text-secondary-foreground">
+                                    <Icon icon={icon} size={24} />
+                                </View>
+                            )}
+                        </View>
+                    );
+
+                    return (
+                        <TouchableOpacity
+                            key={item.name}
+                            // Round, so the keyboard focus outline follows the item's circle.
+                            style={{ borderRadius: 9999 }}
+                            disabled={disabled}
+                            accessibilityRole="button"
+                            accessibilityLabel={item.title}
+                            accessibilityState={{ selected: chosen, disabled }}
+                            // Web drops accessibilityState: say which reactions are chosen, as toggles.
+                            {...(isWeb ? { 'aria-pressed': chosen, 'aria-disabled': disabled } : {})}
+                            onPress={() => {
+                                close();
+                                onSelect(item.name);
+                            }}
+                        >
+                            {isWeb ? <Tooltip content={item.title}>{content}</Tooltip> : content}
+                        </TouchableOpacity>
+                    );
+                })}
+            </View>
+            {note ? (
+                <View className="rounded-full bg-popover px-3 py-1 shadow-card-outline dark:shadow-card-outline-deep">
+                    <Text className="text-xs text-muted-foreground">{note}</Text>
+                </View>
+            ) : null}
+        </View>
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Many mode: the emoji picker
+// ---------------------------------------------------------------------------
+
+function ReactionPicker({
+    items,
+    selected,
+    atLimit,
+    note,
+    onSelect,
+}: {
+    items: ReactionItem[];
+    selected: string[];
+    atLimit: boolean;
+    note?: string;
+    onSelect: (emoji: string) => void;
+}) {
+    const close = useContext(PopoverCloseContext);
+
+    return (
+        <View className="rounded-2xl bg-popover p-2 shadow-card-outline dark:shadow-card-outline-deep">
+            <EmojiPicker
+                quick={items.filter((item) => !!item.emoji).map((item) => ({ emoji: item.emoji, title: item.title }))}
+                selected={selected}
+                isDisabled={() => atLimit}
+                note={note}
+                onSelect={(emoji: string) => {
+                    close();
+                    onSelect(emoji);
+                }}
+            />
+        </View>
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+export default function ElementReactions(props: ReactionsProps) {
+    const { t } = useTranslation();
+    const { currentUser } = useCurrentUser();
+    const playSound = useSound('success');
+
+    const settings = appSetting('social_actions', 'reaction');
+    const params = { ...settings, ...props.params };
+    const action = props.action;
+    const counter = props.counter;
+
+    const key = objectKey(props.type, props.system, props.object_id);
+    const channel = votedChannel(props.system, props.type, props.object_id);
+
+    const settingsItems = settings[props.system]?.items ? settings[props.system].items : params.items;
+    const iconset = settings[props.system]?.iconset ? settings[props.system].iconset : settings.iconset;
+
+    const config = resolveConfig(props.options, settingsItems ?? [], params, iconset, t);
+    const itemsByName: Record<string, ReactionItem> = {};
+    config.items.forEach((item) => (itemsByName[item.name] = item));
+
+    const flags = resolveDisplayFlags(params, props.displayType);
+    const showAction = flags.showAction && !!action && !!action?.title;
+    const showCounter = flags.showCounter && !!counter && !!counter?.items;
+    const showBoth = showAction && showCounter;
+    const showCombined = showBoth && params?.show_combined != undefined && params.show_combined === true;
+
+    const buttonProps = buildButtonProps(props);
+
+    const [state, setState] = useState<ReactionState>({});
+    // A failed request undoes its optimistic change unless the viewer's reactions changed again since (another tap,
+    // their vote from another instance or device). Counts from the server (another member's vote over the socket)
+    // don't block the undo; they replace the optimistic counts instead.
+    const changes = useRef(0);
+    const syncs = useRef(0);
+    const applyUpdate = (next: ReactionState) => {
+        if (next.reactions !== undefined || next.reaction !== undefined || next.is_voted !== undefined) changes.current++;
+        if (next.counter !== undefined) syncs.current++;
+        setState((prev) => mergeState(prev, next));
+    };
+    const [performedBy, setPerformedBy] = useState<Record<string, any[]>>();
+
+    // compound: one modal + selected tab
+    const [compoundPopupOpen, setCompoundPopupOpen] = useState(false);
+    const [compoundTab, setCompoundTab] = useState('');
+
+    // divided: modal per reaction
+    const [dividedPopups, setDividedPopups] = useState<Record<string, boolean>>({});
+
+    const allowViewVoted =
+        settings[props.system]?.allow_view_voted != undefined ? settings[props.system].allow_view_voted : true;
+
+    /** Lucide icon (web svg iconset) or an emoji. */
+    const getIcon = (name: string): string => {
+        const platform = isWeb ? 'web' : 'native';
+        const set = iconset?.[platform]?.[name];
+        if (set?.svg && params['icon_type_' + platform] === 'svg') return set.svg;
+        if (itemsByName[name]?.emoji) return itemsByName[name].emoji;
+        if (set?.emoji) return set.emoji;
+        return emojiFromReactionCode(name) ?? iconset?.[platform]?.default?.emoji ?? '🙂';
+    };
+
+    const getTitle = (name: string): string => {
+        if (itemsByName[name]?.title) return itemsByName[name].title;
+        if (emojiFromReactionCode(name)) return t('Reacted');
+        return t('rvote_' + name + '_title');
+    };
+
+    const object_params = {
+        service: 'TemplVoteServices',
+        system: props.system,
+        objectId: props.object_id,
+    };
+
+    // --- the viewer's reactions ---
+
+    const isVoted = state.is_voted ?? action?.is_voted === true;
+    const isDisabled = state.is_disabled ?? action?.is_disabled === true;
+    const canUndo = action?.is_undo === true;
+
+    let mine: string[] = [];
+    if (state.reactions) mine = state.reactions;
+    else if (Array.isArray(action?.reactions)) mine = action.reactions;
+    else if (isVoted && action?.reaction) mine = [action.reaction];
+    if (!isVoted) mine = [];
+
+    const shown = mine[mine.length - 1] ?? '';
+    const atLimit = config.perUser > 1 && mine.length >= config.perUser;
+    const limitNote = atLimit ? t('You can give up to {{count}} reactions', { count: config.perUser }) : undefined;
+
+    // --- counters (live counts from the vote response / socket win) ---
+
+    // Once a vote has brought a full counter, a missing reaction is at 0: UNA
+    // leaves out picked emoji nobody has any more.
+    const liveCount = (item: CounterItem): number =>
+        Number(state.counter ? state.counter['count_' + item.name] : item.count) || 0;
+
+    const counterItems: CounterItem[] = (() => {
+        const base = (counter?.items ?? []).filter((item) => item?.name && item.name !== 'default');
+        const names = new Set(base.map((item) => item.name));
+        const extra = Object.keys(state.counter ?? {})
+            .filter((k) => k.startsWith('count_'))
+            .map((k) => k.slice(6))
+            .filter((name) => name !== 'default' && !names.has(name))
+            .map((name) => ({ name, count: Number(state.counter?.['count_' + name]) || 0 }));
+        return [...base, ...extra];
+    })();
+
+    // --- do / undo a reaction ---
+
+    const doReaction = (reaction: string) => {
+        if (!reaction) return;
+        const had = mine.includes(reaction);
+        if (!had && atLimit) return;
+
+        FeedbackHaptics(params.haptics_type);
+
+        // Optimistic: the viewer's reactions and the counts.
+        const replaced = !had && config.perUser === 1 ? mine : [];
+        const next = had
+            ? mine.filter((name) => name !== reaction)
+            : config.perUser === 1
+              ? [reaction]
+              : [...mine, reaction];
+
+        const counts: Record<string, number> = {};
+        counterItems.forEach((item) => (counts['count_' + item.name] = liveCount(item)));
+        const bump = (name: string, by: number) => {
+            counts['count_' + name] = Math.max(0, (counts['count_' + name] ?? 0) + by);
+        };
+        bump(reaction, had ? -1 : 1);
+        replaced.forEach((name) => bump(name, -1));
+
+        const before = state;
+        const change = ++changes.current;
+        const sync = syncs.current;
+        const undo = () => {
+            if (changes.current !== change) return;
+            setState((prev) => ({
+                ...prev,
+                is_voted: before.is_voted,
+                reactions: before.reactions,
+                reaction: before.reaction,
+                counter: syncs.current === sync ? before.counter : prev.counter,
+            }));
+        };
+        setState((prev) => ({
+            ...prev,
+            is_voted: next.length > 0,
+            reactions: next,
+            reaction: next[next.length - 1] ?? 'default',
+            counter: { ...(prev.counter ?? {}), ...counts },
+        }));
+
+        objectRequest(object_params, 'do', { value: 1, reaction }, (data: any) => {
+            if (!data || data.code) {
+                undo();
+                return;
+            }
+            // A backend without multiple reactions sends only `reaction`.
+            const reactions = Array.isArray(data.reactions)
+                ? data.reactions
+                : data.is_voted && data.reaction
+                  ? [data.reaction]
+                  : [];
+            shareVote(channel, { ...data, reactions });
+        }).catch(() => {
+            // A timeout or a network error never reaches the callback: undo the optimistic change.
+            undo();
+        });
+        playSound();
+    };
+
+    /** An emoji from the picker: the list's reaction for it, else its e_… code. */
+    const doEmoji = (emoji: string) => {
+        const listed = config.items.find((item) => item.emoji && emojiKey(item.emoji) === emojiKey(emoji));
+        doReaction(listed ? listed.name : reactionCodeFromEmoji(emoji));
+    };
+
+    // --- who reacted (compound / divided) ---
+
+    const openCompoundVoters = (event: any) => {
+        event?.preventDefault?.();
+        if (!allowViewVoted) return;
+        FeedbackHaptics(params.haptics_type);
+
+        objectRequest(object_params, 'get_performed_by', {}, (data: any) => {
+            if (!data?.performed_by) return;
+            setPerformedBy(data.performed_by);
+            setCompoundTab('');
+            setCompoundPopupOpen(true);
+        });
+    };
+
+    const openDividedVoters = (reaction: string, event: any) => {
+        event?.preventDefault?.();
+        if (!allowViewVoted || !reaction) return;
+        FeedbackHaptics(params.haptics_type);
+
+        objectRequest(object_params, 'get_performed_by', { reaction }, (data: any) => {
+            if (!data?.performed_by) return;
+            setPerformedBy(data.performed_by);
+            setDividedPopups((prev) => ({ ...prev, [reaction]: true }));
+        });
+    };
+
+    useVotedSubscription({
+        channel,
+        currentUserId: currentUser ? currentUser.id : 0,
+        onUpdate: applyUpdate,
+    });
+
+    useEffect(() => {
+        const listener = applyUpdate;
+        const listeners = voteListeners.get(channel) ?? new Set();
+        listeners.add(listener);
+        voteListeners.set(channel, listeners);
+        return () => {
+            listeners.delete(listener);
+            if (!listeners.size) voteListeners.delete(channel);
+        };
+    }, [channel]);
+
+    // --- action button ---
+
+    let actionButton: ReactNode = null;
+
+    if (showAction) {
+        const { showAsButton, showLabel } = resolveActionButtonFlags(params);
+        const ActionButton = pickActionButton(showCombined, showAsButton);
+        const hasDefault = !!config.defaultName;
+
+        // What the button shows.
+        let icon: string;
+        let label: string;
+        let spokenLabel: string | undefined;
+        if (config.mode === 'solo' || (hasDefault && !shown)) {
+            icon = getIcon(config.defaultName);
+            label = getTitle(config.defaultName);
+        } else if (hasDefault) {
+            icon = getIcon(shown);
+            label = getTitle(shown);
+            // A picked emoji shows as "Reacted"; name the emoji too ("fox Reacted").
+            const picked = emojiFromReactionCode(shown);
+            if (picked) spokenLabel = picked + ' ' + label;
+        } else {
+            icon = SMILE_PLUS;
+            label = t('React');
+        }
+
+        const pressed = config.mode === 'solo' ? mine.includes(config.defaultName) : isVoted;
+        const disabled = isDisabled && !(isVoted && canUndo);
+
+        // Click: give the default reaction, or take back the one the button shows.
+        const onPress = () => {
+            if (config.mode === 'solo') return doReaction(config.defaultName);
+            if (isVoted && canUndo && shown) return doReaction(shown);
+            return doReaction(config.defaultName);
+        };
+
+        const button = (
+            <ActionButton
+                key="action"
+                startDecorator={icon}
+                title={showLabel ? label : false}
+                accessibilityLabel={spokenLabel ?? label}
+                pressed={pressed}
+                selectedState={pressed ? 'voted' : undefined}
+                disabled={disabled}
+                onPress={onPress}
+                {...buttonProps}
+            />
+        );
+
+        const withChoice = config.mode !== 'solo' && config.items.length > 0;
+        const isMany = config.mode === 'many';
+
+        actionButton = !withChoice ? (
+            button
+        ) : (
+            <ReactionPopover
+                key="action"
+                onPress={onPress}
+                // No default reaction: the button only opens the choice.
+                openOnPress={!hasDefault}
+                hoverOpen={hasDefault}
+                // The picker has a search field; don't close it under the pointer.
+                hoverClose={!isMany}
+                disabled={disabled}
+                haptics={params.haptics_type}
+                childRefProp="forwardedRef"
+                panelWidth={isMany ? PICKER_WIDTH : config.items.length * BAR_ITEM_WIDTH + BAR_PADDING}
+                panelHeight={isMany ? PICKER_HEIGHT : BAR_HEIGHT + (limitNote ? 28 : 0)}
+                trigger={button}
+            >
+                {isMany ? (
+                    <ReactionPicker
+                        items={config.items}
+                        selected={mine.map((name) => itemsByName[name]?.emoji || emojiFromReactionCode(name) || '')}
+                        atLimit={atLimit}
+                        note={limitNote}
+                        onSelect={doEmoji}
+                    />
+                ) : (
+                    <ReactionBar
+                        items={config.items}
+                        getIcon={getIcon}
+                        mine={mine}
+                        atLimit={atLimit}
+                        note={limitNote}
+                        onSelect={doReaction}
+                    />
+                )}
+            </ReactionPopover>
+        );
+    }
+
+    // --- counter ---
+
+    const counterStyle = config.split ? 'divided' : params?.show_counter_style || 'compound';
+    let counterParts: [ReactNode[], ReactNode[]] | false = false;
+
+    if (showCounter) {
+        if (counterStyle === 'divided') {
+            counterParts = buildCounterDivided({
+                t,
+                getIcon,
+                getTitle,
+                onOpenReaction: openDividedVoters,
+                liveCount,
+                items: counterItems,
+                mine,
+                performedBy,
+                popupVisible: dividedPopups,
+                setPopupVisible: setDividedPopups,
+                showCombined,
+                params,
+                buttonProps,
+            });
+        } else {
+            counterParts = buildCounterCompound({
+                t,
+                getIcon,
+                getTitle,
+                onOpen: openCompoundVoters,
+                liveCount,
+                items: counterItems,
+                performedBy,
+                popupVisible: compoundPopupOpen,
+                setPopupVisible: setCompoundPopupOpen,
+                selectedTab: compoundTab,
+                setSelectedTab: setCompoundTab,
+                showCombined,
+                params,
+                buttonProps,
+            });
+        }
+    }
+
+    // --- layout ---
+
+    const hasCounterButtons = showCounter && !!counterParts && counterParts[0].length > 0;
+
+    const combinedGroup: ReactNode[] = [];
+    if (showAction) combinedGroup.push(actionButton);
+    if (counterParts) counterParts[0].forEach((item) => combinedGroup.push(item));
+
+    return (
+        <ActionMenuLayout
+            combined={showCombined}
+            buttonProps={buttonProps}
+            itemKey={key}
+            showAction={showAction}
+            showCounter={showCounter}
+            showBoth={showBoth}
+            params={{
+                ...props.params,
+                counter_flex: 'flex-auto flex-row gap-x-1',
+            }}
+            actionSlots={[
+                {
+                    element: actionButton,
+                    key: key + '-action-button',
+                    marginClass: showBoth && hasCounterButtons ? ' mr-2' : '',
+                },
+            ]}
+            counterButton={counterParts ? counterParts[0] : undefined}
+            counterPopup={counterParts ? counterParts[1] : undefined}
+            combinedGroup={combinedGroup}
+            extraPopups={null}
+            emptyCheck={!((showAction && !!actionButton) || hasCounterButtons)}
+        />
+    );
+}

+ 70 - 0
neo/scripts/generate-emoji-data.js

@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+/**
+ * Writes packages/app/lib/emoji/emoji-data.ts for the emoji picker from the
+ * unicode-emoji-json package (a devDependency): every base emoji (no skin-tone
+ * variants) up to MAX_EMOJI_VERSION that UNA can store as a reaction, with its
+ * name, grouped as in Unicode.
+ *
+ *   node scripts/generate-emoji-data.js
+ *
+ * Newer emoji show as empty boxes on phones that haven't updated their OS, so
+ * raise MAX_EMOJI_VERSION only when most devices support that version.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const MAX_EMOJI_VERSION = 15.0;
+const OUT = path.join(__dirname, '..', 'packages', 'app', 'lib', 'emoji', 'emoji-data.ts');
+
+/**
+ * UNA keeps a picked emoji as `e_<hex code points>` in a varchar(32) column and takes one pictograph (with its
+ * variation selector, skin tone and ZWJ parts), a keycap or a flag: BxDolVoteReactions::isEmoji and normalizeReaction.
+ * Emoji it would refuse (families, couples, England and Scotland flags) stay out of the picker.
+ */
+const PART = '(?:\\p{Extended_Pictographic}(?:\\u{FE0F}|[\\u{1F3FB}-\\u{1F3FF}])?)';
+const UNA_EMOJI = new RegExp(`^(?:${PART}(?:\\u{200D}${PART})*|[#*0-9]\\u{FE0F}?\\u{20E3}|\\p{Regional_Indicator}{2})$`, 'u');
+const UNA_CODE_MAX = 32;
+const fitsUna = (emoji) =>
+    UNA_EMOJI.test(emoji) && ('e_' + Array.from(emoji, (c) => c.codePointAt(0).toString(16)).join('_')).length <= UNA_CODE_MAX;
+
+const groups = require('unicode-emoji-json/data-by-group.json');
+const { version } = require('unicode-emoji-json/package.json');
+
+const out = groups
+    .filter((group) => group.slug !== 'component')
+    .map((group) => ({
+        id: group.slug,
+        title: group.name,
+        emojis: group.emojis
+            .filter((e) => parseFloat(e.emoji_version) <= MAX_EMOJI_VERSION && fitsUna(e.emoji))
+            .map((e) => [e.emoji, e.name]),
+    }))
+    .filter((group) => group.emojis.length > 0);
+
+const count = out.reduce((n, group) => n + group.emojis.length, 0);
+
+/** One emoji per line, so a data update reads as a readable diff. */
+function formatGroup(group) {
+    const emojis = group.emojis.map((entry) => `            ${JSON.stringify(entry)},`).join('\n');
+    return `    {\n        id: ${JSON.stringify(group.id)},\n        title: ${JSON.stringify(group.title)},\n        emojis: [\n${emojis}\n        ],\n    },`;
+}
+
+const body = `// Generated by scripts/generate-emoji-data.js from unicode-emoji-json ${version}. Do not edit.
+// ${count} emoji up to Emoji ${MAX_EMOJI_VERSION.toFixed(1)}, without skin-tone variants.
+
+export type EmojiEntry = readonly [emoji: string, name: string];
+
+export type EmojiGroup = {
+    id: string;
+    title: string;
+    emojis: readonly EmojiEntry[];
+};
+
+export const EMOJI_GROUPS: readonly EmojiGroup[] = [
+${out.map(formatGroup).join('\n')}
+];
+`;
+
+fs.mkdirSync(path.dirname(OUT), { recursive: true });
+fs.writeFileSync(OUT, body);
+console.log(`${path.relative(process.cwd(), OUT)}: ${out.length} groups, ${count} emoji, ${Math.round(body.length / 1024)} KB`);

+ 33 - 0
template/scripts/BxBaseServices.php

@@ -1311,6 +1311,39 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         return $aResults;
     }
 
+    public function serviceGetOptionsVoteReactionsMode()
+    {
+        return [
+            BxDolVoteReactions::$sModeSolo => _t('_adm_stg_cpt_option_sys_vote_reactions_mode_solo'),
+            BxDolVoteReactions::$sModeFew => _t('_adm_stg_cpt_option_sys_vote_reactions_mode_few'),
+            BxDolVoteReactions::$sModeMany => _t('_adm_stg_cpt_option_sys_vote_reactions_mode_many'),
+        ];
+    }
+
+    public function serviceGetOptionsVoteReactionsDefault()
+    {
+        $aResults = [
+            '' => _t('_None')
+        ];
+
+        $aReactions = BxDolFormQuery::getDataItems('sys_vote_reactions', false, BX_DATA_VALUES_ALL);
+        foreach($aReactions as $sName => $aReaction) {
+            $aData = !empty($aReaction['Data']) ? unserialize($aReaction['Data']) : [];
+            $aResults[$sName] = trim((!empty($aData['emoji']) ? $aData['emoji'] . ' ' : '') . _t($aReaction['LKey']));
+        }
+
+        return $aResults;
+    }
+
+    public function serviceGetOptionsVoteReactionsPerUser()
+    {
+        $aResults = [];
+        for($i = 1; $i <= BxDolVoteReactions::$iPerUserMax; $i++)
+            $aResults[$i] = $i;
+
+        return $aResults;
+    }
+
     public function serviceGetOptionsLocationFieldDefault()
     {
         $aResults = array();

+ 63 - 12
template/scripts/BxBaseVoteReactions.php

@@ -92,8 +92,13 @@ class BxBaseVoteReactions extends BxDolVoteReactions
     {
         $aParams = array_merge($this->_aElementDefaults, $aParams);
 
+        //--- 'Split counters' setting, as in the API: a counter per reaction, or one total. A page that asks for
+        //--- the 'simple' counter (a bare number) keeps it.
+        if($aParams['show_counter_style'] != self::$_sCounterStyleSimple)
+            $aParams['show_counter_style'] = $this->isSplitCounters() ? self::$_sCounterStyleDivided : self::$_sCounterStyleCompound;
+
         $sDefault = $aParams['show_counter_style'];
-        $sCounterStyle = !empty($aParams['show_counter_style']) ? $aParams['show_counter_style'] : $sDefault;
+        $sCounterStyle = $aParams['show_counter_style'];
 
         $sMethodPrefix = '_getCounter';
         $sMethod = $sMethodPrefix . bx_gen_method_name($sCounterStyle);
@@ -108,20 +113,27 @@ class BxBaseVoteReactions extends BxDolVoteReactions
         $aParams = array_merge($this->_aElementDefaultsApi, $aParams);
 
         $aVote = $this->_getVote();
-        $aReactions = $this->getReactions();
+        $aReactions = $this->getCountedReactions($aVote);
 
         $aItems = [];
         foreach($aReactions as $sName) {
+            $aReaction = $this->getReaction($sName);
             $iCount = (int)$aVote['count_' . $sName];
             $aItems[] = [
                 'name' => $sName,
                 'count' => $iCount,
                 'sum' => $aVote['sum_' . $sName],
-                'rate' => $aVote['rate_' . $sName]
+                'rate' => $aVote['rate_' . $sName],
+                'emoji' => $aReaction !== false ? $aReaction['emoji'] : '',
+                'title' => $aReaction !== false ? _t($aReaction['title']) : ''
             ];
         }
 
-        $sStyle = !empty($aParams['show_counter_style']) ? $aParams['show_counter_style'] : $this->_aElementDefaults['show_counter_style'];
+        //--- 'Split counters' setting: a counter per reaction.
+        if($this->isSplitCounters())
+            $sStyle = self::$_sCounterStyleDivided;
+        else
+            $sStyle = !empty($aParams['show_counter_style']) ? $aParams['show_counter_style'] : $this->_aElementDefaults['show_counter_style'];
 
         return ['type' => $sStyle, 'items' => $aItems];
     }
@@ -134,7 +146,7 @@ class BxBaseVoteReactions extends BxDolVoteReactions
 
         $bVote = $this->_isVote();
         $aVote = $this->_getVote();
-        $aReactions = $this->getReactions();
+        $aReactions = $this->getCountedReactions($aVote);
 
         $sClass = isset($aParams['class_counter']) ? $aParams['class_counter'] : '';
         if(isset($aParams['show_do_vote_as_button_small']) && (bool)$aParams['show_do_vote_as_button_small'] === true)
@@ -194,7 +206,7 @@ class BxBaseVoteReactions extends BxDolVoteReactions
 
         $bVote = $this->_isVote();
         $aVote = $this->_getVote();
-        $aReactions = $this->getReactions();
+        $aReactions = $this->getCountedReactions($aVote);
 
         $sClass = isset($aParams['class_counter']) ? $aParams['class_counter'] : '';
         if(isset($aParams['show_do_vote_as_button_small']) && (bool)$aParams['show_do_vote_as_button_small'] === true)
@@ -248,7 +260,7 @@ class BxBaseVoteReactions extends BxDolVoteReactions
 
         $bVote = $this->_isVote();
         $aVote = $this->_getVote();
-        $aReactions = $this->getReactions();
+        $aReactions = $this->getCountedReactions($aVote);
 
         $sClass = isset($aParams['class_counter']) ? $aParams['class_counter'] : '';
         if(isset($aParams['show_do_vote_as_button_small']) && (bool)$aParams['show_do_vote_as_button_small'] === true)
@@ -348,14 +360,48 @@ class BxBaseVoteReactions extends BxDolVoteReactions
         foreach($aReactions as $sName)
             $aT[$sName] = _t($this->_aDataList[$sName]['title']);
 
-        return parent::getElementAPI(array_merge($aParams, [
+        $aResult = parent::getElementAPI(array_merge($aParams, [
             't' => $aT
         ]));
+
+        if(is_array($aResult) && isset($aResult['type']))
+            $aResult['options'] = $this->getOptionsAPI();
+
+        return $aResult;
+    }
+
+    /**
+     * Reactions settings and the list, for clients that build their own picker.
+     */
+    public function getOptionsAPI()
+    {
+        $aItems = [];
+        foreach($this->getReactions(true) as $sName => $aReaction) {
+            if($sName === $this->_sDefault)
+                continue;
+
+            $aItems[] = [
+                'name' => $sName,
+                'title' => _t($aReaction['title']),
+                'emoji' => $aReaction['emoji'],
+            ];
+        }
+
+        return [
+            'mode' => $this->getMode(),
+            'default' => $this->getDefaultReaction(),
+            'per_user' => $this->getPerUser(),
+            'split_counters' => $this->isSplitCounters(),
+            'items' => $aItems,
+        ];
     }
 
     public function getPerformedByAPI($aParams = [])
     {
-        $aReactions = !empty($aParams['reaction']) ? [$aParams['reaction']] : $this->getReactions();
+        if(!empty($aParams['reaction']))
+            $aReactions = $this->getReaction($aParams['reaction']) !== false ? [$aParams['reaction']] : [];
+        else
+            $aReactions = $this->getCountedReactions($this->_getVote());
 
         $aResult = [];
         foreach($aReactions as $sReaction) {
@@ -402,7 +448,10 @@ class BxBaseVoteReactions extends BxDolVoteReactions
     	$bVoted = isset($aParams['is_voted']) && (bool)$aParams['is_voted'] === true;
         $bShowDoVoteAsButtonSmall = isset($aParams['show_do_vote_as_button_small']) && (bool)$aParams['show_do_vote_as_button_small'] === true;
         $bShowDoVoteAsButton = !$bShowDoVoteAsButtonSmall && isset($aParams['show_do_vote_as_button']) && (bool)$aParams['show_do_vote_as_button'] === true;
-        $bDisabled = !$isAllowedVote || ($bVoted && !$bUndo);
+
+        //--- With several reactions per person allowed, one can add more until the limit.
+        $aReactionsBy = $bVoted ? $this->getReactionsBy($this->getId(), $this->_getAuthorId()) : [];
+        $bDisabled = !$isAllowedVote || ($bVoted && !$bUndo && count($aReactionsBy) >= $this->getPerUser());
 
         $sClass = '';
         if($bShowDoVoteAsButton)
@@ -436,7 +485,9 @@ class BxBaseVoteReactions extends BxDolVoteReactions
                 'is_voted' => $bVoted,
                 'is_disabled' => $bDisabled,
                 'value' => $iValue,
-                'reaction' => $bVoted ? $sReaction : $this->_sDefault,
+                'reaction' => $bVoted ? ($sReaction !== '' ? $sReaction : $aParams['track']['reaction']) : $this->_sDefault,
+                'reactions' => $aReactionsBy,
+                'default' => $this->getDefaultReaction(),
                 'title' => _t($this->_getTitleDoWithTrack($bVoted, $aParams['track'])),
             ];
 
@@ -553,7 +604,7 @@ class BxBaseVoteReactions extends BxDolVoteReactions
                             'image' => $sUse == 'image' ? $this->getImage($sReaction) : '',
                         ]
                     ],
-                    'title_attr' => bx_html_attribute(_t($this->_aDataList[$sReaction]['title'])),
+                    'title_attr' => bx_html_attribute($aReaction !== false ? _t($aReaction['title']) : ''),
                 )
             ),
             'bx_if:show_text' => array(

+ 280 - 0
tests/integration/vote/ReactionsModesTest.php

@@ -0,0 +1,280 @@
+<?php
+
+use PHPUnit\Framework\Attributes\Group;
+
+/**
+ * Reactions settings: mode (solo / few / many), default reaction, reactions per person
+ * and free-picked emoji, which are stored as e_<hex> codes.
+ */
+#[Group('integration')]
+class ReactionsModesTest extends BxDolIntegrationTestCase
+{
+    private const SYSTEM = 'bx_timeline_reactions';
+    private const OPTIONS = ['sys_vote_reactions_mode', 'sys_vote_reactions_default', 'sys_vote_reactions_per_user', 'sys_vote_reactions_split_counters'];
+
+    private array $_aOptionsBackup = [];
+    private int $_iEventId = 0;
+    /** The test member's reactions to the event before the test, given back in tearDown. */
+    private array $_aReactionsBackup = [];
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        foreach (self::OPTIONS as $sName)
+            $this->_aOptionsBackup[$sName] = getParam($sName);
+    }
+
+    protected function tearDown(): void
+    {
+        unset($_GET['value'], $_GET['reaction']);
+
+        if ($this->_iEventId && ($oVote = $this->reactions($this->_iEventId))) {
+            $iAuthorId = bx_get_logged_profile_id();
+            foreach ($oVote->getReactionsBy($this->_iEventId, $iAuthorId) as $sReaction)
+                $oVote->vote(['reaction' => $sReaction]);
+
+            //--- Give back what the member had before the test, in a mode that takes any of it.
+            $this->configure('many', 'like', 5);
+            $oVote = $this->reactions($this->_iEventId);
+            foreach ($this->_aReactionsBackup as $sReaction)
+                $oVote->vote(['reaction' => $sReaction]);
+        }
+
+        foreach ($this->_aOptionsBackup as $sName => $sValue)
+            setParam($sName, $sValue);
+
+        parent::tearDown();
+    }
+
+    private function configure(string $sMode, string $sDefault = 'like', int $iPerUser = 1): void
+    {
+        setParam('sys_vote_reactions_mode', $sMode);
+        setParam('sys_vote_reactions_default', $sDefault);
+        setParam('sys_vote_reactions_per_user', (string)$iPerUser);
+    }
+
+    /**
+     * A fresh instance: settings are read in the constructor and instances are cached.
+     */
+    private function reactions(int $iObjectId = 1): ?BxDolVoteReactions
+    {
+        if (!BxDolVote::getObjectInstance(self::SYSTEM, $iObjectId, false))
+            return null;
+
+        unset($GLOBALS['bxDolClasses']['BxDolVote!' . self::SYSTEM . $iObjectId]);
+        $oVote = BxDolVote::getObjectInstance(self::SYSTEM, $iObjectId);
+
+        return $oVote instanceof BxDolVoteReactions ? $oVote : null;
+    }
+
+    private function reactionsOrSkip(int $iObjectId = 1): BxDolVoteReactions
+    {
+        $oVote = $this->reactions($iObjectId);
+        if (!$oVote)
+            $this->markTestSkipped(self::SYSTEM . ' is not installed.');
+
+        return $oVote;
+    }
+
+    public function testSettingsHaveTheirOwnSection(): void
+    {
+        $oDb = BxDolDb::getInstance();
+        $aCategory = $oDb->getRow("SELECT `id`, `caption`, `hidden` FROM `sys_options_categories` WHERE `name` = 'reactions'");
+        $this->assertNotEmpty($aCategory, 'Studio > Settings > Global has a Reactions section');
+        $this->assertSame(0, (int)$aCategory['hidden']);
+        $this->assertNotSame($aCategory['caption'], _t($aCategory['caption']), 'the section has a title');
+
+        $aOptions = $oDb->getPairs("SELECT `name`, `category_id` FROM `sys_options` WHERE `name` IN ('" . implode("', '", self::OPTIONS) . "')", 'name', 'category_id');
+        $this->assertSame(self::OPTIONS, array_keys(array_intersect_key(array_flip(self::OPTIONS), $aOptions)), 'all reaction options exist');
+        foreach ($aOptions as $sName => $iCategoryId)
+            $this->assertSame((int)$aCategory['id'], (int)$iCategoryId, $sName . ' is in the Reactions section');
+    }
+
+    public function testWebCounterFollowsSplitSetting(): void
+    {
+        $oVote = $this->memberOnEventOrSkip('few', 1);
+        $sWrapper = 'counter-wrapper-' . BX_DOL_VOTE_TYPE_REACTIONS . '-';
+
+        setParam('sys_vote_reactions_split_counters', 'on');
+        $oVote = $this->reactionsOrSkip($this->_iEventId);
+        $this->assertStringContainsString($sWrapper . 'divided', $oVote->getCounter(), 'Split counters: a counter per reaction.');
+        $this->assertStringContainsString($sWrapper . 'divided', $oVote->getCounter(['show_counter_style' => 'compound']));
+
+        setParam('sys_vote_reactions_split_counters', '');
+        $oVote = $this->reactionsOrSkip($this->_iEventId);
+        $this->assertStringContainsString($sWrapper . 'compound', $oVote->getCounter(), 'One total, as NEO shows it.');
+        $this->assertStringContainsString($sWrapper . 'compound', $oVote->getCounter(['show_counter_style' => 'divided']));
+
+        $this->assertStringNotContainsString($sWrapper . 'compound', $oVote->getCounter(['show_counter_style' => 'simple']), 'A simple counter stays simple.');
+    }
+
+    public function testEmojiValidation(): void
+    {
+        foreach (['😀', '❤️', '👍🏽', '🏳️‍🌈', '1️⃣', '🇺🇦'] as $sEmoji)
+            $this->assertTrue(BxDolVoteReactions::isEmoji($sEmoji), $sEmoji . ' is an emoji.');
+
+        foreach (['', 'a', 'like', '😀😀', '<b>', '😀 '] as $sText)
+            $this->assertFalse(BxDolVoteReactions::isEmoji($sText), '"' . $sText . '" is not one emoji.');
+    }
+
+    public function testEmojiCodeRoundTrip(): void
+    {
+        $this->assertSame('e_1f600', BxDolVoteReactions::encodeEmoji('😀'));
+        $this->assertSame('e_2764_fe0f', BxDolVoteReactions::encodeEmoji('❤️'));
+        $this->assertSame('❤️', BxDolVoteReactions::decodeEmoji('e_2764_fe0f'));
+
+        foreach (['e_', 'e_zz', 'e_61', 'like', 'e_d800', 'e_110000'] as $sCode)
+            $this->assertFalse(BxDolVoteReactions::decodeEmoji($sCode), $sCode . ' is not an emoji code.');
+    }
+
+    public function testFewModeTakesOnlyListedReactions(): void
+    {
+        $this->configure('few');
+        $oVote = $this->reactionsOrSkip();
+
+        $this->assertSame('like', $oVote->normalizeReaction('like'));
+        $this->assertFalse($oVote->normalizeReaction('😀'));
+        $this->assertFalse($oVote->normalizeReaction('e_1f600'));
+        $this->assertFalse($oVote->normalizeReaction('nope'));
+    }
+
+    public function testManyModeTakesAnyEmoji(): void
+    {
+        $this->configure('many');
+        $oVote = $this->reactionsOrSkip();
+
+        $this->assertSame('e_1f600', $oVote->normalizeReaction('😀'));
+        $this->assertSame('e_1f600', $oVote->normalizeReaction('e_1f600'));
+        $this->assertSame('like', $oVote->normalizeReaction('👍'), 'An emoji on the list is that reaction.');
+        $this->assertFalse($oVote->normalizeReaction('text'));
+        $this->assertFalse($oVote->normalizeReaction('👨‍👩‍👧‍👦'), 'Codes longer than the column are refused.');
+
+        $aReaction = $oVote->getReaction('e_1f600');
+        $this->assertIsArray($aReaction);
+        $this->assertSame('😀', $aReaction['emoji']);
+    }
+
+    public function testSoloModeKeepsOnlyTheDefault(): void
+    {
+        $this->configure('solo', 'love');
+        $oVote = $this->reactionsOrSkip();
+
+        $this->assertSame('solo', $oVote->getMode());
+        $this->assertSame('love', $oVote->getDefaultReaction());
+        $this->assertSame(1, $oVote->getPerUser());
+        $this->assertSame('love', $oVote->normalizeReaction('love'));
+        $this->assertFalse($oVote->normalizeReaction('like'));
+    }
+
+    public function testUnknownDefaultMeansNone(): void
+    {
+        $this->configure('few', 'no_such_reaction');
+        $this->assertSame('', $this->reactionsOrSkip()->getDefaultReaction());
+    }
+
+    /**
+     * The test member on the latest timeline event, with their reactions to it set aside (tearDown gives them back),
+     * in the given mode.
+     */
+    private function memberOnEventOrSkip(string $sMode, int $iPerUser): BxDolVoteReactions
+    {
+        if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_timeline'))
+            $this->markTestSkipped('Timeline is not installed.');
+
+        $iEventId = (int)BxDolDb::getInstance()->getOne("SELECT `id` FROM `bx_timeline_events` WHERE `active` = 1 ORDER BY `id` DESC LIMIT 1");
+        if (!$iEventId)
+            $this->markTestSkipped('There are no timeline events to react to.');
+
+        $this->bxLoginAsUser();
+        $this->configure($sMode, 'like', $iPerUser);
+        $oVote = $this->reactionsOrSkip($iEventId);
+        if (!$oVote->isAllowedVote())
+            $this->markTestSkipped('The test user may not react to timeline events: ' . strip_tags((string)$oVote->msgErrAllowedVote()));
+
+        $this->_iEventId = $iEventId;
+        $this->_aReactionsBackup = $oVote->getReactionsBy($iEventId, bx_get_logged_profile_id());
+        foreach ($this->_aReactionsBackup as $sReaction)
+            $oVote->vote(['reaction' => $sReaction]);
+
+        return $oVote;
+    }
+
+    /**
+     * The reaction a classic web request (`actionVote`) asks for, as `_getVoteData` reads it, or false.
+     */
+    private function webVoteReaction(BxDolVoteReactions $oVote, string $sReaction)
+    {
+        $_GET['value'] = '1';
+        $_GET['reaction'] = $sReaction;
+        $aData = (new ReflectionMethod($oVote, '_getVoteData'))->invoke($oVote);
+
+        return $aData === false ? false : $aData['reaction'];
+    }
+
+    public function testWebTakesBackLeftoverReaction(): void
+    {
+        $oVote = $this->memberOnEventOrSkip('many', 2);
+        $this->assertSame(0, (int)$oVote->vote(['reaction' => '😀'])['code']);
+
+        //--- Few mode no longer offers the emoji, but its owner can still send it back to take it back.
+        $this->configure('few', 'like', 2);
+        $oVote = $this->reactionsOrSkip($this->_iEventId);
+        $this->assertSame('e_1f600', $this->webVoteReaction($oVote, 'e_1f600'));
+        $this->assertSame('e_1f600', $this->webVoteReaction($oVote, '😀'));
+        $this->assertFalse($this->webVoteReaction($oVote, 'e_1f602'), 'An emoji one does not have is refused.');
+        $this->assertSame('love', $this->webVoteReaction($oVote, 'love'));
+
+        $aResult = $oVote->vote(['reaction' => $this->webVoteReaction($oVote, 'e_1f600')]);
+        $this->assertSame(0, (int)$aResult['code']);
+        $this->assertSame([], $aResult['api']['reactions']);
+    }
+
+    public function testMemberReactionsWithLimitAndReplace(): void
+    {
+        //--- Two reactions allowed: both stay, a third is refused, giving one again takes it back.
+        $oVote = $this->memberOnEventOrSkip('many', 2);
+
+        $this->assertSame(0, (int)$oVote->vote(['reaction' => 'like'])['code']);
+        $aResult = $oVote->vote(['reaction' => '😀']);
+        $this->assertSame(0, (int)$aResult['code']);
+        $this->assertSame(['like', 'e_1f600'], $aResult['api']['reactions']);
+        $this->assertSame(1, (int)$aResult['api']['counter']['count_e_1f600']);
+
+        $aResult = $oVote->vote(['reaction' => 'love']);
+        $this->assertSame(BX_DOL_OBJECT_ERR_DUPLICATE, (int)$aResult['code']);
+
+        $aResult = $oVote->vote(['reaction' => 'e_1f600']);
+        $this->assertSame(0, (int)$aResult['code']);
+        $this->assertSame(['like'], $aResult['api']['reactions']);
+
+        //--- One reaction allowed: a new one replaces the old one.
+        $this->configure('few', 'like', 1);
+        $oVote = $this->reactionsOrSkip($this->_iEventId);
+
+        $aResult = $oVote->vote(['reaction' => 'love']);
+        $this->assertSame(0, (int)$aResult['code']);
+        $this->assertSame(['love'], $aResult['api']['reactions']);
+        $this->assertSame('love', $aResult['api']['reaction']);
+
+        //--- Few mode refuses a free emoji.
+        $this->assertSame(BX_DOL_OBJECT_ERR_WRONG_DATE, (int)$oVote->vote(['reaction' => '😀'])['code']);
+
+        //--- Reactions the settings no longer offer can still be taken back.
+        $this->configure('many', 'like', 2);
+        $oVote = $this->reactionsOrSkip($this->_iEventId);
+        $this->assertSame(['love', 'e_1f600'], $oVote->vote(['reaction' => '😀'])['api']['reactions']);
+
+        $this->configure('solo', 'like');
+        $oVote = $this->reactionsOrSkip($this->_iEventId);
+        $this->assertSame(BX_DOL_OBJECT_ERR_WRONG_DATE, (int)$oVote->vote(['reaction' => 'e_1f602'])['code'], 'An emoji one does not have is refused.');
+
+        $aResult = $oVote->vote(['reaction' => 'e_1f600']);
+        $this->assertSame(0, (int)$aResult['code']);
+        $this->assertSame(['love'], $aResult['api']['reactions']);
+
+        $aResult = $oVote->vote(['reaction' => 'love']);
+        $this->assertSame(0, (int)$aResult['code'], 'Solo mode with "like" still takes back "love".');
+        $this->assertSame([], $aResult['api']['reactions']);
+    }
+}