Просмотр исходного кода

Likes can be undone; cast likes and reactions get an accent background (#5815)

* Likes can be undone; cast likes and reactions get an accent background

UNA only undid a vote when its sys_objects_vote row had IsUndo = 1, and every
module ships its likes with IsUndo = 0 (reactions with 1), so a second tap on
Like was refused as a duplicate and NEO showed the like faded and disabled.
BxDolVoteLikes::isUndo() now returns true for likes whatever the flag says;
poll answer votes and polls subentries, which are likes only in form, set
$_bUndoAlways = false and keep following IsUndo.

NEO: a cast like or reaction uses a new `voted` state on the borderless button
style (bg-accent/60, accent label) instead of the shared pressedToggle wash
(bg-muted), so other selected borderless buttons are unchanged.

Also centres the score counter's number on its icon: the rolling 20px slots
kept the label's 16px line-height, which put the number 2px high.

Tests: tests/integration/vote/LikesUndoTest.php (passes on this branch; the
undo cases fail on master with "Duplicate vote").

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

* Keep a selected look for voted buttons in every style; tighten the likes test

- The default action style is `secondary` (bordered), and only borderless
  defined `voted`, so a cast like or reaction rendered there lost its selected
  background. Bordered gets `voted: bg-accent/60`, and the resolver falls back
  to a style's `pressedToggle` for any custom selected state it doesn't define.
- LikesUndoTest restores the member's vote in `finally`, and the poll check
  also covers bx_polls_subentries (BxPollsVoteSubentries).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 1 неделя назад
Родитель
Сommit
301cd6a5ed

+ 12 - 0
inc/classes/BxDolVoteLikes.php

@@ -12,6 +12,13 @@
  */
 class BxDolVoteLikes extends BxTemplVote
 {
+    /**
+     * A like can always be taken back, whatever the object's IsUndo setting.
+     * Votes that are likes only in form (poll answers) set it to false to
+     * keep IsUndo.
+     */
+    protected $_bUndoAlways = true;
+
     public function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
     {
         parent::__construct($sSystem, $iId, $iInit, $oTemplate);
@@ -28,6 +35,11 @@ class BxDolVoteLikes extends BxTemplVote
         return (int)$this->_aSystem['min_value'];
     }
 
+    public function isUndo()
+    {
+        return $this->_bUndoAlways || parent::isUndo();
+    }
+
     /**
      * Internal functions
      */

+ 3 - 0
modules/base/general/classes/BxBaseModGeneralVotePollAnswers.php

@@ -26,6 +26,9 @@ class BxBaseModGeneralVotePollAnswers extends BxTemplVoteLikes
 
     protected $_sTmplNameElementBlock;
 
+    // A poll vote follows the object's IsUndo setting, unlike a like.
+    protected $_bUndoAlways = false;
+
     function __construct($sSystem, $iId, $iInit = 1)
     {
     	$this->_oModule = BxDolModule::getInstance($this->_sModule);

+ 3 - 0
modules/boonex/polls/classes/BxPollsVoteSubentries.php

@@ -19,6 +19,9 @@ class BxPollsVoteSubentries extends BxTemplVoteLikes
 
     protected $_sTmplNameElementBlock;
 
+    // A poll vote follows the object's IsUndo setting, unlike a like.
+    protected $_bUndoAlways = false;
+
     function __construct($sSystem, $iId, $iInit = 1)
     {
     	$this->_sModule = 'bx_polls';

+ 8 - 1
neo/packages/app/design/controls/neo-button/neo-button-resolver.tsx

@@ -387,10 +387,17 @@ const SWIFTUI_BORDER_SHAPE: Record<string, string> = {
  * pressedToggle, disabled }) and resolves each entry against ctx, then
  * concatenates `base` + the entry for the current state.
  */
+// The interaction states every style may define. Any other state is a custom
+// selected state (`selectedState="voted"`).
+const NEO_BUTTON_STATES = new Set(['default', 'hovered', 'focused', 'pressed', 'active', 'pressedToggle', 'disabled']);
+
 function resolveSlot(slotMap: Record<string, any> | undefined, state: string, ctx: NeoScopeContext): string {
     if (!slotMap) return '';
     const base = resolveScoped(slotMap.base, ctx) || '';
-    const stateVal = resolveScoped(slotMap[state], ctx);
+    let stateVal = resolveScoped(slotMap[state], ctx);
+    // A style without that custom selected state keeps its own selected look.
+    if (stateVal === undefined && !NEO_BUTTON_STATES.has(state))
+        stateVal = resolveScoped(slotMap.pressedToggle, ctx);
     const fallback = state !== 'default' ? resolveScoped(slotMap.default, ctx) : undefined;
     const main = (stateVal !== undefined ? stateVal : fallback) || '';
     return [base, main].filter(Boolean).join(' ');

+ 6 - 0
neo/packages/app/settings/theme/buttons.js

@@ -410,6 +410,8 @@ export const settingsButtons = {
                     active: 'bg-button-hover ',
                     // Selected must differ from hover; matches the native expo_ui bordered toggle.
                     pressedToggle: 'bg-accent',
+                    // A cast like or reaction (selectedState="voted").
+                    voted: 'bg-accent/60',
                     disabled: 'bg-button/50 ',
                 },
                 text: {
@@ -420,6 +422,7 @@ export const settingsButtons = {
                     pressed: 'text-button-foreground',
                     active: 'text-button-foreground',
                     pressedToggle: 'text-accent-foreground',
+                    voted: 'text-accent-foreground',
                     disabled: 'text-muted-foreground',
                 },
             },
@@ -458,6 +461,8 @@ export const settingsButtons = {
                     pressed: 'bg-accent/80 web:hover:bg-accent',
                     active: 'bg-muted/50 ',
                     pressedToggle: 'bg-muted',
+                    // A cast like or reaction (selectedState="voted" in objects/likes, reactions).
+                    voted: 'bg-accent/60',
                     disabled: 'opacity-50',
                 },
                 text: {
@@ -468,6 +473,7 @@ export const settingsButtons = {
                     pressed: 'text-accent-foreground',
                     active: 'text-foreground',
                     pressedToggle: 'text-accent-foreground',
+                    voted: 'text-accent-foreground',
                     disabled: 'text-secondary-foreground',
                 },
             },

+ 1 - 0
neo/packages/app/ui/molecules/objects/likes.js

@@ -125,6 +125,7 @@ export default function ElementLikes(props) {
                 title={showLabel ? title : false}
                 onPress={undoLike}
                 pressed={true}
+                selectedState="voted"
                 {...buttonProps}
             />
         );

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

@@ -632,6 +632,7 @@ export default function ElementReactions(props) {
                     startDecorator={getIcon(reaction)}
                     title={showLabel ? title : ''}
                     pressed={true}
+                    selectedState="voted"
                     onPress={undoReaction}
                     {...buttonProps}
                 />

+ 2 - 2
neo/packages/app/ui/molecules/objects/scores.js

@@ -210,10 +210,10 @@ export default function ElementScore(props) {
                         counterClass
                     }
                 >
-                    <Text className={'sv-old block h-5'}>
+                    <Text className={'sv-old block h-5 leading-5'}>
                         {scoreOld.toString()}
                     </Text>
-                    <Text className={'sv-new block h-5'}>
+                    <Text className={'sv-new block h-5 leading-5'}>
                         {score.toString()}
                     </Text>
                 </Text>

+ 77 - 0
tests/integration/vote/LikesUndoTest.php

@@ -0,0 +1,77 @@
+<?php
+
+use PHPUnit\Framework\Attributes\Group;
+
+/**
+ * Likes can always be taken back, whatever the vote object's IsUndo setting;
+ * poll answer votes, which are likes only in form, keep that setting.
+ */
+#[Group('integration')]
+class LikesUndoTest extends BxDolIntegrationTestCase
+{
+    public function testLikeObjectsAreUndoableWhateverIsUndo(): void
+    {
+        $aObjects = BxDolDb::getInstance()->getColumn("SELECT `Name` FROM `sys_objects_vote` WHERE `IsUndo` = 0 AND `Name` IN ('sys_cmts', 'bx_timeline', 'bx_posts', 'bx_persons')");
+        if (empty($aObjects))
+            $this->markTestSkipped('No like objects with IsUndo = 0 are installed.');
+
+        foreach ($aObjects as $sName) {
+            $oVote = BxDolVote::getObjectInstance($sName, 1, false);
+            if (!$oVote instanceof BxDolVoteLikes)
+                continue;
+
+            $this->assertTrue($oVote->isUndo(), $sName . ' likes should be undoable.');
+        }
+    }
+
+    public function testPollAnswerVotesKeepIsUndo(): void
+    {
+        $aRows = BxDolDb::getInstance()->getAll("SELECT `Name`, `IsUndo` FROM `sys_objects_vote` WHERE `Name` LIKE '%\\_poll\\_answers' OR `Name` = 'bx_polls_subentries'");
+        if (empty($aRows))
+            $this->markTestSkipped('No poll answer vote objects are installed.');
+
+        foreach ($aRows as $aRow) {
+            $oVote = BxDolVote::getObjectInstance($aRow['Name'], 1, false);
+            if (!$oVote)
+                continue;
+
+            $this->assertSame((int)$aRow['IsUndo'] === 1, $oVote->isUndo(), $aRow['Name'] . ' should follow IsUndo.');
+        }
+    }
+
+    public function testMemberCanLikeAndTakeItBack(): void
+    {
+        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 like.');
+
+        $this->bxLoginAsUser();
+
+        $oVote = BxDolVote::getObjectInstance('bx_timeline', $iEventId);
+        $this->assertInstanceOf(BxDolVoteLikes::class, $oVote);
+        if (!$oVote->isAllowedVote())
+            $this->markTestSkipped('The test user may not like timeline events.');
+
+        $iProfileId = bx_get_logged_profile_id();
+        $bVotedBefore = (bool)$oVote->isPerformed($iEventId, $iProfileId);
+
+        try {
+            $aFirst = $oVote->vote(['value' => 1]);
+            $this->assertSame(0, (int)($aFirst['code'] ?? 0), 'First vote failed: ' . json_encode($aFirst));
+            $this->assertSame(!$bVotedBefore, $aFirst['api']['is_voted']);
+
+            // Before IsUndo was ignored for likes, this second vote was refused as a duplicate.
+            $aSecond = $oVote->vote(['value' => 1]);
+            $this->assertSame(0, (int)($aSecond['code'] ?? 0), 'Second vote failed: ' . json_encode($aSecond));
+            $this->assertSame($bVotedBefore, $aSecond['api']['is_voted']);
+        }
+        finally {
+            // Leave the member's vote as it was, even when an assertion above failed.
+            if ((bool)$oVote->isPerformed($iEventId, $iProfileId) !== $bVotedBefore)
+                $oVote->vote(['value' => 1]);
+        }
+    }
+}