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

Discussions: category, author and reactions on NEO cards and pages (#5917)

* Discussions: category, author and reactions on NEO cards and pages

- The API sends every discussion's category. Before, it was sent only when
  the category had an icon row in Studio, so new sites showed an empty
  square and no "in General". The icon falls back to `folder`, as on the
  site, and an uploaded icon goes out as its URL.
- NEO discussion cards: the author's avatar on the left (the feed's size),
  then "Name · 1d · in [icon] Category" in text-sm above the title. The
  category icon may be an icon name, an emoji, SVG markup or an image URL.
  Phones get a square thumbnail.
- Reactions on discussions in NEO, with the Studio reaction settings: the
  card meta sends a reactions element (the base snippet meta now supports
  it in the API), and the discussion page's action bar is on for the app.
  NEO's post layout shows an action bar UNA keeps in the top cell after the
  text, where Posts show theirs.

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

* Discussions: card meta truncates, forum API test makes its own fixtures

- The card meta row beside the author's name can shrink, and the category
  name truncates, so a long name no longer runs over the thumbnail on a
  phone (checked at 375px).
- ForumApiTest creates and removes its own categorized discussion, so it
  runs on a clean site, and turns reactions on in memory for its one test
  and back off after.
- Uploaded icons: a stored image's id must come back as its exact URL;
  a missing file stays a separate case that must come back empty.
- The shared menu's `_bIsApi` and the vote instance are restored in a
  `finally` block.

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

* Discussions: forum API test removes both temp files

tempnam() creates its own empty file before the test appends `.png`, so
that file was left behind on every run. Both are removed in a `finally`.

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

---------

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

+ 7 - 3
modules/base/general/classes/BxBaseModGeneralMenuSnippetMeta.php

@@ -237,9 +237,6 @@ class BxBaseModGeneralMenuSnippetMeta extends BxTemplMenuUnitMeta
     
     protected function _getMenuItemReactions($aItem)
     {
-        if($this->_bIsApi) //--- API: Isn't supported
-            return false;
-
         $CNF = &$this->_oModule->_oConfig->CNF;
 
         $sKo = 'OBJECT_REACTIONS';
@@ -250,6 +247,13 @@ class BxBaseModGeneralMenuSnippetMeta extends BxTemplMenuUnitMeta
         if(!$oObject || !$oObject->isEnabled())
             return false;
 
+        //--- API: the reaction button with its counter, as the timeline sends it.
+        if($this->_bIsApi)
+            return $this->_getMenuItemElementAPI($aItem, $oObject->getElementApi([
+                'show_counter' => true,
+                'show_counter_style' => 'compound'
+            ]));
+
         return $this->getUnitMetaItemCustom($oObject->getCounter([
             'show_counter_style' => 'compound',
             'show_counter_empty' => false, 

+ 18 - 10
modules/boonex/forum/classes/BxForumModule.php

@@ -21,17 +21,25 @@ class BxForumModule extends BxBaseModTextModule
         $CNF = $this->_oConfig->CNF;
         
         $aResult = parent::decodeDataApi($aData, $bExtended);
-        
-        $oCategory = BxDolCategory::getObjectInstance($CNF['OBJECT_CATEGORY']);
-        if(!$oCategory)
-            return false;
 
-        $aCategoryData = $this->_oDb->getCategories(['type' => 'by_category', 'category' => $aData[$CNF['FIELD_CATEGORY']]]);
-        if(!empty($aCategoryData) &&is_array($aCategoryData))
-            $aResult['category'] = [
-                'name' => $oCategory->getCategoryTitle($aData[$CNF['FIELD_CATEGORY']]), 
-                'icon' => $aCategoryData['icon']
-            ];
+        $iCategory = (int)($aData[$CNF['FIELD_CATEGORY']] ?? 0);
+        $oCategory = BxDolCategory::getObjectInstance($CNF['OBJECT_CATEGORY']);
+        if(!$iCategory || !$oCategory)
+            return $aResult;
+
+        /*
+         * A category has a row in the categories table only after its icon is set in Studio,
+         * so fall back to the icon the site's meta uses. An uploaded icon is a storage id.
+         */
+        $aCategoryData = $this->_oDb->getCategories(['type' => 'by_category', 'category' => $iCategory]);
+        $sIcon = !empty($aCategoryData['icon']) ? $aCategoryData['icon'] : 'folder';
+        if(is_numeric($sIcon))
+            $sIcon = (string)BxDolStorage::getObjectInstance(BX_DOL_STORAGE_OBJ_IMAGES)->getFileUrlById((int)$sIcon);
+
+        $aResult['category'] = [
+            'name' => $oCategory->getCategoryTitle($iCategory),
+            'icon' => $sIcon
+        ];
 
         return $aResult;
     }

+ 2 - 0
modules/boonex/forum/install/sql/enable-app.sql

@@ -58,6 +58,8 @@ UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_forum_entry_atta
 UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_forum_entry_attachments' AND `module`='bx_forum' AND `name`='file_html5';
 UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_forum_entry_attachments' AND `module`='bx_forum' AND `name`='poll';
 UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_forum_entry_attachments' AND `module`='bx_forum' AND `name`='add-link';
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_forum_view_actions' AND `module`='bx_forum' AND `name`='reaction';
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_forum_snippet_meta_main' AND `module`='bx_forum' AND `name`='reactions';
 UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_forum_view' AND `module`='bx_forum' AND `name`='resolve-discussion';
 UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_forum_view' AND `module`='bx_forum' AND `name`='stick-discussion';
 UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_forum_view' AND `module`='bx_forum' AND `name`='lock-discussion';

+ 5 - 4
modules/boonex/forum/install/sql/enable.sql

@@ -335,10 +335,11 @@ INSERT INTO `sys_menu_items`(`set_name`, `module`, `name`, `title_system`, `titl
 ('bx_forum_snippet_meta_main', 'bx_forum', 'author', '_sys_menu_item_title_system_sm_author', '_sys_menu_item_title_sm_author', '', '', '', '', '', 2147483647, 1, 0, 1, 1, 0),
 ('bx_forum_snippet_meta_main', 'bx_forum', 'date', '_sys_menu_item_title_system_sm_date', '_sys_menu_item_title_sm_date', '', '', '', 'clock', '', 2147483647, 1, 0, 1, 2, 0),
 ('bx_forum_snippet_meta_main', 'bx_forum', 'category', '_sys_menu_item_title_system_sm_category', '_sys_menu_item_title_sm_category', '', '', '', '', '', 2147483647, 1, 0, 1, 3, 3),
-('bx_forum_snippet_meta_main', 'bx_forum', 'comments', '_sys_menu_item_title_system_sm_comments', '_sys_menu_item_title_sm_comments', '', '', '', 'comments', '', 2147483647, 1, 0, 1, 4, 3),
-('bx_forum_snippet_meta_main', 'bx_forum', 'sticked', '_bx_forum_menu_item_title_system_sm_sticked', '_bx_forum_menu_item_title_sm_sticked', '', '', '', 'thumbtack', '', 2147483647, 1, 0, 1, 5, 3),
-('bx_forum_snippet_meta_main', 'bx_forum', 'locked', '_bx_forum_menu_item_title_system_sm_locked', '_bx_forum_menu_item_title_sm_locked', '', '', '', 'lock', '', 2147483647, 0, 0, 1, 6, 3),
-('bx_forum_snippet_meta_main', 'bx_forum', 'status', '_bx_forum_menu_item_title_system_sm_status', '_bx_forum_menu_item_title_sm_status', '', '', '', '', '', 2147483647, 1, 0, 1, 7, 3);
+('bx_forum_snippet_meta_main', 'bx_forum', 'reactions', '_sys_menu_item_title_system_sm_reactions', '_sys_menu_item_title_sm_reactions', '', '', '', '', '', 2147483647, 0, 0, 1, 4, 3),
+('bx_forum_snippet_meta_main', 'bx_forum', 'comments', '_sys_menu_item_title_system_sm_comments', '_sys_menu_item_title_sm_comments', '', '', '', 'comments', '', 2147483647, 1, 0, 1, 5, 3),
+('bx_forum_snippet_meta_main', 'bx_forum', 'sticked', '_bx_forum_menu_item_title_system_sm_sticked', '_bx_forum_menu_item_title_sm_sticked', '', '', '', 'thumbtack', '', 2147483647, 1, 0, 1, 6, 3),
+('bx_forum_snippet_meta_main', 'bx_forum', 'locked', '_bx_forum_menu_item_title_system_sm_locked', '_bx_forum_menu_item_title_sm_locked', '', '', '', 'lock', '', 2147483647, 0, 0, 1, 7, 3),
+('bx_forum_snippet_meta_main', 'bx_forum', 'status', '_bx_forum_menu_item_title_system_sm_status', '_bx_forum_menu_item_title_sm_status', '', '', '', '', '', 2147483647, 1, 0, 1, 8, 3);
 
 
 -- MENU: custom menu for 'counters' snippet meta info

+ 11 - 1
neo/packages/app/components/page-layout/post.js

@@ -210,7 +210,17 @@ export default function PageLayout({ data, blocks, isModal = false, url, pageCla
         }
     }, [])
 
-    const { leftBarBlocks, sideBarBlocks, centerBlocks } = defineCells(blocks, data);
+    const { leftBarBlocks, sideBarBlocks, topBlocks, centerBlocks: cellCenterBlocks } = defineCells(blocks, data);
+
+    // UNA puts some entities' action bars (Discussions) in the top cell, where the
+    // web site draws them above the content. Show them after the text, where Posts
+    // keep theirs. Other top blocks stay off this layout.
+    const topActionBlocks = topBlocks.filter((item) => blockHasContentType(item, data, 'entity_actions'));
+    const textIndex = cellCenterBlocks.findIndex((item) => blockHasContentType(item, data, 'entity_text'));
+    const actionsAt = textIndex < 0 ? 0 : textIndex + 1;
+    const centerBlocks = topActionBlocks.length
+        ? [...cellCenterBlocks.slice(0, actionsAt), ...topActionBlocks, ...cellCenterBlocks.slice(actionsAt)]
+        : cellCenterBlocks;
 
     // Desktop page keeps INFO in the right column. When that column folds
     // (mobile, or the post modal) those blocks used to append after the body.

+ 56 - 28
neo/packages/app/components/units/content-list/bx-forum.js

@@ -3,6 +3,10 @@ import { View, Row } from "app/design/view";
 import Menu from "app/components/menu";
 import Time from "app/ui/atoms/time";
 import { Skeleton } from 'app/ui/atoms/skeleton'
+import { Icon } from 'app/ui/atoms/icon'
+import Image from 'app/ui/atoms/image'
+import { isEmoji } from 'app/lib/util'
+import Profile from 'app/ui/molecules/profile/profile'
 import {
     UnitAuthor,
     UnitImage,
@@ -16,6 +20,17 @@ import { useTranslation } from 'react-i18next'
 
 const Units = {};
 
+// A Studio category icon is an emoji, an icon name, SVG markup or an uploaded image URL.
+function CategoryIcon({ icon }) {
+    if (!icon || typeof icon !== 'string')
+        return null
+    if (/^https?:\/\//.test(icon))
+        return <Image src={icon} alt="" width={16} height={16} contentFit="contain" nobg />
+    if (isEmoji(icon))
+        return <Text className="text-sm leading-none">{icon}</Text>
+    return <Icon icon={icon} size={16} className="text-muted-foreground" />
+}
+
 Units.Search = function Search({ data }) {
     const isSkeleton = data?.skeleton
     return (
@@ -92,43 +107,56 @@ Units.Base = function Base({ data }) {
     return (
         <UnitWrapper data={data} as="list" className="flex-row gap-x-3 px-4">
             <View className="flex-none pt-0.5">
-                <Skeleton className="h-10 w-10" rounded="rounded-lg" visible={isSkeleton}>
-                    <View className="h-10 w-10 border border-primary/10 bg-primary/10 rounded-lg items-center justify-center overflow-hidden">
-                        <Text className="text-xl leading-none">
-                            {categoryIcon}
-                        </Text>
-                    </View>
+                <Skeleton className="h-11 w-11" rounded="rounded-full" visible={isSkeleton}>
+                    <Profile {...data.author_data} displayType="unit_wo_info" displaySize="base" emulate />
                 </Skeleton>
             </View>
 
             <View className="gap-y-2 flex-auto min-w-0">
                 <View className="flex-row gap-x-3">
-                    <View className="gap-y-1.5 flex-auto min-w-0">
+                    <View className="gap-y-1 flex-auto min-w-0">
+                        <Skeleton preset="author" visible={isSkeleton}>
+                            <Profile
+                                {...data.author_data}
+                                displayType="unit_wo_image"
+                                displaySize="sm"
+                                showInfo={false}
+                                showInfo2={
+                                    // Shrinks beside the name: a long category name truncates
+                                    // instead of running over the thumbnail.
+                                    <Row className="flex-1 min-w-0 items-center gap-1">
+                                        {postedTs ? (
+                                            <>
+                                                <Text className="flex-none text-sm text-muted-foreground">·</Text>
+                                                <Time
+                                                    stylesName="flex-none text-sm text-muted-foreground"
+                                                    ts={postedTs}
+                                                />
+                                            </>
+                                        ) : null}
+                                        {categoryName ? (
+                                            <>
+                                                <Text className="flex-none text-sm text-muted-foreground">·</Text>
+                                                <Text className="flex-none text-sm text-muted-foreground">{t('in')}</Text>
+                                                <View className="flex-none">
+                                                    <CategoryIcon icon={categoryIcon} />
+                                                </View>
+                                                <Text numberOfLines={1} className="flex-1 min-w-0 text-sm font-semibold text-foreground">
+                                                    {categoryName}
+                                                </Text>
+                                            </>
+                                        ) : null}
+                                    </Row>
+                                }
+                                emulate
+                            />
+                        </Skeleton>
+
                         <UnitTitle
                             title={data.title}
                             skeleton={isSkeleton}
                         />
 
-                        <UnitAuthor authorData={data.author_data} skeleton={isSkeleton} displaySize="sm">
-                            <Row className="items-center gap-1">
-                                {postedTs ? (
-                                    <Time
-                                        stylesName="text-xs text-muted-foreground"
-                                        ts={postedTs}
-                                    />
-                                ) : null}
-                                {categoryName ? (
-                                    <>
-                                        {postedTs ? <Text className="text-xs text-muted-foreground">·</Text> : null}
-                                        <Text className="text-xs text-muted-foreground">{t('in')}</Text>
-                                        <Text numberOfLines={1} className="text-xs font-semibold text-foreground">
-                                            {categoryName}
-                                        </Text>
-                                    </>
-                                ) : null}
-                            </Row>
-                        </UnitAuthor>
-
                         {(data.summary_plain || isSkeleton) ? (
                             <UnitText
                                 text={data.summary_plain}
@@ -143,7 +171,7 @@ Units.Base = function Base({ data }) {
                         <View
                             className={
                                 (!data.image ? 'hidden sm:block ' : '')
-                                + 'aspect-video flex-none rounded-xl overflow-hidden w-1/4 sm:w-auto sm:h-20'
+                                + 'aspect-square sm:aspect-video self-start flex-none rounded-xl overflow-hidden w-20 sm:w-auto sm:h-20'
                             }
                         >
                             <UnitImage

+ 179 - 0
tests/integration/forum/ForumApiTest.php

@@ -0,0 +1,179 @@
+<?php
+
+use PHPUnit\Framework\Attributes\Group;
+
+/**
+ * Discussions for the app: every discussion names its category (with the
+ * icon the site uses when Studio sets none), and the unit meta sends the
+ * reactions element.
+ *
+ * The test makes its own discussion and image, and restores the category
+ * icon row it changes, so it runs on any site with Discussions installed.
+ */
+#[Group('integration')]
+class ForumApiTest extends BxDolIntegrationTestCase
+{
+    protected $_aGetBackup;
+    protected $_aContent;
+    protected $_aCategoryRow;
+    protected $_iImageId = 0;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->_aGetBackup = $_GET;
+
+        if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_forum'))
+            $this->markTestSkipped('Discussions are not installed.');
+
+        $oDb = BxDolDb::getInstance();
+        $iCategory = (int)$oDb->getOne("SELECT `Value` FROM `sys_form_pre_values` WHERE `Key` = 'bx_forum_cats' AND `Value` <> '' ORDER BY `Order` LIMIT 1");
+        $this->assertGreaterThan(0, $iCategory, 'Discussions install at least one category.');
+
+        $iAuthor = (int)BxDolProfile::getInstanceAccountProfile($this->bxAccount('admin')->id())->id();
+        $iNow = time();
+        $oDb->query("INSERT INTO `bx_forum_discussions` SET `author` = :author, `added` = :now, `changed` = :now, `lr_timestamp` = :now, `thumb` = 0, `thumb_data` = '', `title` = 'ForumApiTest discussion', `cat` = :cat, `multicat` = '', `text` = '<p>Fixture</p>', `text_comments` = '', `lr_profile_id` = :author, `lr_comment_id` = 0, `labels` = ''", [
+            'author' => $iAuthor,
+            'now' => $iNow,
+            'cat' => $iCategory,
+        ]);
+        $this->_aContent = $oDb->getRow("SELECT * FROM `bx_forum_discussions` WHERE `id` = :id", ['id' => (int)$oDb->lastId()]);
+
+        // The tests change the category's icon row; keep the original to restore it.
+        $this->_aCategoryRow = $oDb->getRow("SELECT * FROM `bx_forum_categories` WHERE `category` = :category", ['category' => $iCategory]);
+    }
+
+    protected function tearDown(): void
+    {
+        $oDb = BxDolDb::getInstance();
+        if (!empty($this->_aContent)) {
+            $iCategory = (int)$this->_aContent['cat'];
+            $oDb->query("DELETE FROM `bx_forum_categories` WHERE `category` = :category", ['category' => $iCategory]);
+            if (!empty($this->_aCategoryRow))
+                $oDb->query("INSERT INTO `bx_forum_categories` SET `category` = :category, `visible_for_levels` = :levels, `icon` = :icon", [
+                    'category' => $iCategory,
+                    'levels' => (int)$this->_aCategoryRow['visible_for_levels'],
+                    'icon' => $this->_aCategoryRow['icon'],
+                ]);
+            $oDb->query("DELETE FROM `bx_forum_discussions` WHERE `id` = :id", ['id' => (int)$this->_aContent['id']]);
+        }
+        if ($this->_iImageId)
+            BxDolStorage::getObjectInstance(BX_DOL_STORAGE_OBJ_IMAGES)->deleteFile($this->_iImageId, 0);
+        $oDb->cleanMemoryAll();
+
+        $_GET = $this->_aGetBackup ?? [];
+        parent::tearDown();
+    }
+
+    protected function setCategoryIcon(?string $sIcon): void
+    {
+        $oDb = BxDolDb::getInstance();
+        $iCategory = (int)$this->_aContent['cat'];
+        $oDb->query("DELETE FROM `bx_forum_categories` WHERE `category` = :category", ['category' => $iCategory]);
+        if ($sIcon !== null)
+            $oDb->query("INSERT INTO `bx_forum_categories` SET `category` = :category, `icon` = :icon", ['category' => $iCategory, 'icon' => $sIcon]);
+        $oDb->cleanMemoryAll();
+    }
+
+    protected function decode(array $aContent): array
+    {
+        $aResult = BxDolModule::getInstance('bx_forum')->decodeDataApi($aContent);
+        $this->assertIsArray($aResult);
+
+        return $aResult;
+    }
+
+    public function testCategoryWithoutIconRowFallsBackToFolder(): void
+    {
+        $this->setCategoryIcon(null);
+
+        $aResult = $this->decode($this->_aContent);
+
+        $this->assertArrayHasKey('category', $aResult);
+        $this->assertNotSame('', $aResult['category']['name']);
+        $this->assertSame('folder', $aResult['category']['icon']);
+    }
+
+    public function testCategoryIconFromStudio(): void
+    {
+        $this->setCategoryIcon('💬');
+
+        $this->assertSame('💬', $this->decode($this->_aContent)['category']['icon']);
+    }
+
+    public function testUploadedIconIsSentAsItsUrl(): void
+    {
+        // tempnam() creates its own file; the image goes next to it with a .png name.
+        $sTmp = tempnam(sys_get_temp_dir(), 'bxforum');
+        $sPath = $sTmp . '.png';
+        $oStorage = BxDolStorage::getObjectInstance(BX_DOL_STORAGE_OBJ_IMAGES);
+        try {
+            copy(BX_DIRECTORY_PATH_ROOT . 'template/images/acl-account.png', $sPath);
+            $this->_iImageId = (int)$oStorage->storeFileFromPath($sPath, false, 0);
+        }
+        finally {
+            @unlink($sPath);
+            @unlink($sTmp);
+        }
+        if (!$this->_iImageId)
+            $this->fail('Store the icon image: ' . $oStorage->getErrorString());
+
+        $this->setCategoryIcon((string)$this->_iImageId);
+
+        $sUrl = $oStorage->getFileUrlById($this->_iImageId);
+        $this->assertNotEmpty($sUrl);
+        $this->assertSame($sUrl, $this->decode($this->_aContent)['category']['icon']);
+    }
+
+    public function testMissingUploadedIconIsNeverTheBareId(): void
+    {
+        // A storage id with no file behind it: no URL, and never the id.
+        $this->setCategoryIcon('2147483000');
+
+        $this->assertSame('', $this->decode($this->_aContent)['category']['icon']);
+    }
+
+    public function testNoCategoryNoKey(): void
+    {
+        $aResult = $this->decode(array_merge($this->_aContent, ['cat' => 0]));
+
+        $this->assertArrayNotHasKey('category', $aResult);
+    }
+
+    public function testUnitMetaSendsReactionsElement(): void
+    {
+        $this->bxLoginAsAdmin();
+        $_GET['api'] = 1;
+
+        // Reactions on for this test only (in memory, the site setting is untouched).
+        $aSystems = &BxDolVote::getSystems();
+        $iIsOn = $aSystems['bx_forum_reactions']['is_on'] ?? null;
+        $this->assertNotNull($iIsOn, 'Discussions install the bx_forum_reactions object.');
+        $aSystems['bx_forum_reactions']['is_on'] = 1;
+        $sVoteKey = 'BxDolVote!bx_forum_reactions' . (int)$this->_aContent['id'];
+        unset($GLOBALS['bxDolClasses'][$sVoteKey]);
+
+        $oMenu = BxDolMenu::getObjectInstance('bx_forum_snippet_meta_main');
+        $this->assertInstanceOf(BxBaseModGeneralMenuSnippetMeta::class, $oMenu);
+        $oIsApi = new ReflectionProperty($oMenu, '_bIsApi');
+        $bIsApi = $oIsApi->getValue($oMenu);
+
+        try {
+            $oIsApi->setValue($oMenu, true);
+            $oMenu->setContentId((int)$this->_aContent['id']);
+
+            $aItem = $this->bxCallProtected($oMenu, '_getMenuItemReactions', ['id' => 1, 'name' => 'reactions']);
+        }
+        finally {
+            // The menu and vote instances are shared: put them back as they were.
+            $oIsApi->setValue($oMenu, $bIsApi);
+            $aSystems['bx_forum_reactions']['is_on'] = $iIsOn;
+            unset($GLOBALS['bxDolClasses'][$sVoteKey]);
+        }
+
+        $this->assertIsArray($aItem);
+        $this->assertSame('element', $aItem['display_type']);
+        $this->assertSame('reactions', $aItem['data']['type']);
+        $this->assertSame((int)$this->_aContent['id'], (int)$aItem['data']['object_id']);
+    }
+}