Răsfoiți Sursa

Messenger: chat actions, favorites, saved messages and search in the … (#5866)

* Messenger: chat actions, favorites, saved messages and search in the API and NEO

UNA (bx_messenger):
- Talk actions menu for the app: bx_messenger_lot_info_menu is requested on
  demand (system/get_menu with params.lot); the talk menu items (star, mute,
  video call, ...) are merged in for the API, the site keeps both menus as they are.
- New services: clear_convo, mute_convo, star_convo, save_jot,
  search_convo_messages, get_convo_files, get_convo_settings, save_convo_settings.
- Favorite chats: 'favorites' talks list (starred by the viewer) and an
  app-only nav menu item (active=0, active_api=1).
- Talks and messages carry 'starred' and 'saved', loaded with one query per list.
- Talks search in the API looks in titles and participants' names; users search
  uses the profile modules' quick search fields instead of the global search.
- Fixes: saved messages area filtered by the viewer; missing space in the star
  filter join; participants search with API profile data ('id' instead of 'value').
- findInHistory can skip deleted messages; 'Saved' menu title is 'Saved messages'.

Core:
- BxDolProfile::getData accepts 'with_online' to add the online status.

NEO:
- Chat header: gear menu loaded on demand, inline message search, clear history,
  settings, files, mute and star actions.
- Conversations list: favorites and saved messages sections, star badge,
  online dot, unread badge on the title line, refresh on new messages via sockets.
- Messages: save to saved messages, saved mark, 'new messages' line with the
  talk opened on it, conversation info layout.
- Autofocus for opened search fields and the add users field.

* Messenger: review fixes, TypeScript for the reworked NEO files, braces for Sonar

NEO:
- common, headers, item-jot and jots-panel moved to TypeScript (git mv), with
  shared types in messenger.types.ts; untyped JS components are cast at import.
- Gear menu: stable items / onSelect, so the native menu no longer reopens on
  every header re-render.
- Saved section: incoming socket messages aren't added (they aren't saved), and
  a message removed from saved leaves the list (an emptied talk refreshes the list).
- Edited message from the socket keeps the viewer's own 'saved' flag.
- Clear history checks the result code before clearing the transcript.
- In-chat search shows results and 'Load more' only for the current query.

UNA:
- Curly braces around the bodies of the new if / foreach statements (Sonar).

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

* Messenger: explicit visibility for BxMessengerDb::getSavedJotsByIds (Sonar php:S1784)

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

* Messenger: search within the open section, saved paging and per-message saved state

- search_lots takes 'group': favorites search only starred talks, direct only
  private ones, saved only talks with messages the viewer saved
  (BxMessengerDb::getSavedLotIds). NEO sends the open section with the term.
- Saved section: messages removed from saved are subtracted from the offset of
  the next page, so no older saved message is skipped.
- ItemJot keeps its local saved state with the message id: a reused row doesn't
  show another message's mark or title.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL 6 zile în urmă
părinte
comite
45f5018f9f
27 a modificat fișierele cu 1789 adăugiri și 338 ștergeri
  1. 5 0
      inc/classes/BxDolProfile.php
  2. 74 7
      modules/boonex/messenger/classes/BxMessengerDb.php
  3. 61 0
      modules/boonex/messenger/classes/BxMessengerLotInfoMenu.php
  4. 13 1
      modules/boonex/messenger/classes/BxMessengerLotMenu.php
  5. 13 0
      modules/boonex/messenger/classes/BxMessengerModule.php
  6. 394 6
      modules/boonex/messenger/classes/BxMessengerServices.php
  7. 28 13
      modules/boonex/messenger/classes/BxMessengerTemplate.php
  8. 2 1
      modules/boonex/messenger/install/langs/en.xml
  9. 2 1
      modules/boonex/messenger/install/langs/ru.xml
  10. 1 0
      modules/boonex/messenger/install/sql/enable-app.sql
  11. 2 1
      modules/boonex/messenger/install/sql/enable.sql
  12. 23 5
      neo/packages/app/components/elements/chat/parts/convos-list-item.tsx
  13. 13 6
      neo/packages/app/components/elements/chat/parts/headers.tsx
  14. 8 5
      neo/packages/app/components/elements/chat/parts/message-item.tsx
  15. 2 2
      neo/packages/app/components/elements/messenger.js
  16. 334 121
      neo/packages/app/components/elements/messenger/parts/common.tsx
  17. 104 0
      neo/packages/app/components/elements/messenger/parts/convo-files.tsx
  18. 116 0
      neo/packages/app/components/elements/messenger/parts/convo-search.tsx
  19. 67 0
      neo/packages/app/components/elements/messenger/parts/convo-settings.tsx
  20. 0 136
      neo/packages/app/components/elements/messenger/parts/headers.js
  21. 279 0
      neo/packages/app/components/elements/messenger/parts/headers.tsx
  22. 3 0
      neo/packages/app/components/elements/messenger/parts/item-convo.js
  23. 91 28
      neo/packages/app/components/elements/messenger/parts/item-jot.tsx
  24. 52 3
      neo/packages/app/components/elements/messenger/parts/jots-panel.tsx
  25. 99 0
      neo/packages/app/components/elements/messenger/parts/messenger.types.ts
  26. 1 1
      neo/packages/app/components/elements/messenger/parts/new-convo.js
  27. 2 1
      neo/packages/app/components/form-fields/initial-members.js

+ 5 - 0
inc/classes/BxDolProfile.php

@@ -153,6 +153,11 @@ class BxDolProfile extends BxDolFactory implements iBxDolProfile
             'module' => $oProfile->getModule(),
         ];
 
+        // Online status (the online dot on the site), only on request: it costs a query per profile.
+        if(isset($aParams['with_online']) && (bool)$aParams['with_online']) {
+            $aRv['online'] = $oProfile->isOnline() ? 1 : 0;
+        }
+
         if(isset($aParams['with_info']) && (bool)$aParams['with_info'])
             $aRv['info'] = bx_srv($oProfile->getModule(), 'get_info', [$oProfile->getContentId(), false]);
         

+ 74 - 7
modules/boonex/messenger/classes/BxMessengerDb.php

@@ -290,6 +290,24 @@ class BxMessengerDb extends BxBaseModGeneralDb
         return $iNew;
     }
 
+    /**
+     * Talks from the list that the member marked with a star
+     *@param int $iProfileId profile id
+     *@param array $aLotIds lots ids
+     *@return array lots ids
+     */
+    public function getStarredLots($iProfileId, $aLotIds)
+    {
+        $aLotIds = array_filter(array_map('intval', (array)$aLotIds));
+        if (!$iProfileId || empty($aLotIds)) {
+            return [];
+        }
+
+        return $this->getColumn("SELECT `{$this->CNF['FIELD_INFO_LOT_ID']}` FROM `{$this->CNF['TABLE_USERS_INFO']}`
+                                    WHERE `{$this->CNF['FIELD_INFO_USER_ID']}` = :profile AND `{$this->CNF['FIELD_INFO_STAR']}` = 1
+                                    AND `{$this->CNF['FIELD_INFO_LOT_ID']}` IN (" . implode(',', $aLotIds) . ")", ['profile' => (int)$iProfileId]);
+    }
+
     public function isStarred($iLotId, $iParticipant)
     {
         return $this->getParams($iLotId, $iParticipant, 'star', true);
@@ -826,7 +844,7 @@ class BxMessengerDb extends BxBaseModGeneralDb
             case BX_MSG_TALK_TYPE_THREADS:
                 break;
             case BX_MSG_TALK_TYPE_SAVED:
-                $sJoin = "INNER JOIN `{$CNF['TABLE_SAVED_JOTS']}` as `s` ON `s`.`{$CNF['FSJ_ID']}` = `m`.`{$CNF['FIELD_MESSAGE_ID']}`";
+                $sJoin = "INNER JOIN `{$CNF['TABLE_SAVED_JOTS']}` as `s` ON `s`.`{$CNF['FSJ_ID']}` = `m`.`{$CNF['FIELD_MESSAGE_ID']}` AND `s`.`{$CNF['FSJ_PROFILE_ID']}` = " . (int)$iProfileId;
                 $sWhere = "AND `s`.`{$CNF['FSJ_ID']}` IS NOT NULL";
                 break;
             case BX_MSG_TALK_TYPE_REPLIES:
@@ -1296,20 +1314,31 @@ class BxMessengerDb extends BxBaseModGeneralDb
         if (isset($aParams['threads']))
             $aSWhere[] = " `l`.`{$this->CNF['FIELD_PARENT_JOT']}` !=0 ";
 
+        // Search criteria: the caller's (API) or the ones selected in the module settings.
+        $aCriteria = isset($aParams['search_criteria']) && is_array($aParams['search_criteria']) ? $aParams['search_criteria'] : null;
+        $fIsCriteria = function($sCriteria) use ($aCriteria) {
+            return $aCriteria !== null ? in_array($sCriteria, $aCriteria) : $this->_oConfig->isSearchCriteria($sCriteria);
+        };
+
         $sParam = isset($aParams['term']) && $aParams['term'] ? $aParams['term'] : '';
         if ($sParam) {
             $aParamWhere = [];
-            if ($this->_oConfig->isSearchCriteria(BX_SEARCH_CRITERIA_TITLES)) {
+            if ($fIsCriteria(BX_SEARCH_CRITERIA_TITLES)) {
                 $aParamWhere[] = "`l`.`{$this->CNF['FIELD_TITLE']}` LIKE :title";
                 $aWhere['title'] = "%{$sParam}%";
             }
 
-            if ($this->_oConfig->isSearchCriteria(BX_SEARCH_CRITERIA_PARTS)) {
+            if ($fIsCriteria(BX_SEARCH_CRITERIA_PARTS)) {
                 $aProfiles = BxDolService::call('system', 'profiles_search', array($sParam), 'TemplServiceProfiles');
                 if (!empty($aProfiles)) {
                     $aRegexp = array();
-                    foreach ($aProfiles as &$aProfile)
-                        $aRegexp[] = "(^|,){$aProfile['value']}(,|$)";
+                    foreach ($aProfiles as &$aProfile) {
+                        // Site: 'value' holds the profile id; API: profiles come as unit data with 'id'.
+                        $iPartId = (int)(isset($aProfile['value']) ? $aProfile['value'] : (isset($aProfile['id']) ? $aProfile['id'] : 0));
+                        if ($iPartId) {
+                            $aRegexp[] = "(^|,){$iPartId}(,|$)";
+                        }
+                    }
 
                     if (!empty($aRegexp)) {
                         $aWhere['part_search'] = implode('|', $aRegexp);
@@ -1318,7 +1347,7 @@ class BxMessengerDb extends BxBaseModGeneralDb
                 }
             }
 
-            if ($this->_oConfig->isSearchCriteria(BX_SEARCH_CRITERIA_CONTENT)) {
+            if ($fIsCriteria(BX_SEARCH_CRITERIA_CONTENT)) {
                 $aSelectedLots = $this->searchMessage($sParam, $iProfileId);
                 if (!empty($aSelectedLots)) {
                     $aParamWhere[] = "`l`.`{$this->CNF['FIELD_ID']}` IN (" . implode(',', array_unique($aSelectedLots)) . ")";
@@ -2204,7 +2233,7 @@ class BxMessengerDb extends BxBaseModGeneralDb
                                         ORDER BY `{$CNF['FIELD_REACT_ADDED']}`", array('jot_id' => $iJotId));
     }
 
-    public function findInHistory($iLotId = 0, $sText = '', $iStart = 0, $iLimit = 10, $sOrder = 'DESC')
+    public function findInHistory($iLotId = 0, $sText = '', $iStart = 0, $iLimit = 10, $sOrder = 'DESC', $bSkipDeleted = false)
     {
         if (!$sText)
             return array();
@@ -2217,6 +2246,10 @@ class BxMessengerDb extends BxBaseModGeneralDb
             $aParams['id'] = $iLotId;
         }
 
+        if ($bSkipDeleted) {
+            $aWhere[] = " `j`.`{$this->CNF['FIELD_MESSAGE_TRASH']}` = 0 ";
+        }
+
         $sLimit = '';
         $sOrder = $sOrder === 'ASC' ? 'ASC' : 'DESC';
         if ($iLimit) {
@@ -2499,6 +2532,40 @@ class BxMessengerDb extends BxBaseModGeneralDb
                                                     `{$this->CNF['FSJ_PROFILE_ID']}`=:profile_id", array('jot_id' => $iJotId, 'profile_id' => $iProfileId));
     }
 
+    /**
+     * Talks where the member has saved messages
+     * @param int $iProfileId profile id
+     * @return array lots ids
+     */
+    public function getSavedLotIds($iProfileId)
+    {
+        if (!$iProfileId) {
+            return [];
+        }
+
+        return array_map('intval', $this->getColumn("SELECT DISTINCT `m`.`{$this->CNF['FIELD_MESSAGE_FK']}`
+                                    FROM `{$this->CNF['TABLE_SAVED_JOTS']}` AS `s`
+                                    INNER JOIN `{$this->CNF['TABLE_MESSAGES']}` AS `m` ON `m`.`{$this->CNF['FIELD_MESSAGE_ID']}` = `s`.`{$this->CNF['FSJ_ID']}`
+                                    WHERE `s`.`{$this->CNF['FSJ_PROFILE_ID']}` = :profile_id", ['profile_id' => (int)$iProfileId]));
+    }
+
+    /**
+     * Messages from the list that the member saved
+     * @param int $iProfileId profile id
+     * @param array $aJotIds jots ids
+     * @return array jots ids
+     */
+    public function getSavedJotsByIds($iProfileId, $aJotIds)
+    {
+        $aJotIds = array_filter(array_map('intval', (array)$aJotIds));
+        if (!$iProfileId || empty($aJotIds)) {
+            return [];
+        }
+
+        return $this->getColumn("SELECT `{$this->CNF['FSJ_ID']}` FROM `{$this->CNF['TABLE_SAVED_JOTS']}`
+                                    WHERE `{$this->CNF['FSJ_PROFILE_ID']}` = :profile_id AND `{$this->CNF['FSJ_ID']}` IN (" . implode(',', $aJotIds) . ")", ['profile_id' => (int)$iProfileId]);
+    }
+
     function isJotSaved($iJotId, $iProfileId)
     {
         return $this->getOne(

+ 61 - 0
modules/boonex/messenger/classes/BxMessengerLotInfoMenu.php

@@ -22,6 +22,67 @@ class BxMessengerLotInfoMenu extends BxBaseModTextMenu
         parent::__construct($aObject, $this->_oModule->_oTemplate);
     }
 
+    public function setContentId($iContentId)
+    {
+        parent::setContentId($iContentId);
+
+        $this->_aContentParams = array_merge((array)$this->_aContentParams, ['lot' => (int)$this->_iContentId]);
+    }
+
+    public function initContentParams()
+    {
+        parent::initContentParams();
+
+        $this->_aContentParams['lot'] = (int)$this->_iContentId;
+    }
+
+    /**
+     * API: the menu is requested on demand (system/get_menu) with the talk in params.
+     */
+    public function setContentParams($aParams)
+    {
+        if(empty($aParams['lot'])) {
+            return false;
+        }
+
+        $this->setContentId((int)$aParams['lot']);
+
+        return parent::setContentParams($aParams);
+    }
+
+    /**
+     * API: one menu for the talk header. Items of the talk menu (video call, star, mute, ...) go first,
+     * its 'settings' item (which opens this menu on the site) is left out. The site keeps both menus as they are.
+     */
+    public function getCodeAPI ()
+    {
+        $aResult = parent::getCodeAPI();
+        if(!$this->_iContentId) {
+            return $aResult;
+        }
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $oMenuLot = BxTemplMenu::getObjectInstance($CNF['OBJECT_MENU_ACTIONS_TALK_MENU']);
+        if(!$oMenuLot) {
+            return $aResult;
+        }
+
+        $oMenuLot->setContentId($this->_iContentId);
+        $aMenuLot = $oMenuLot->getCodeAPI();
+        if(empty($aMenuLot['items']) || !is_array($aMenuLot['items'])) {
+            return $aResult;
+        }
+
+        $aItems = array_values(array_filter($aMenuLot['items'], function($aItem) {
+            return $aItem['name'] != 'settings';
+        }));
+
+        $aResult['items'] = array_merge($aItems, is_array($aResult['items']) ? $aResult['items'] : []);
+
+        return $aResult;
+    }
+
     protected function _isVisible ($a)
     {
         $iProfileId = bx_get_logged_profile_id();

+ 13 - 1
modules/boonex/messenger/classes/BxMessengerLotMenu.php

@@ -59,7 +59,14 @@ class BxMessengerLotMenu extends BxBaseModTextMenu
                 $aMenu['value'] = '';
         }
 
-        return parent::_getMenuItem($aMenu);
+        $mixedResult = parent::_getMenuItem($aMenu);
+
+        // API: current state of toggles (star, mute) for the app.
+        if($this->_bIsApi && is_array($mixedResult)) {
+            $mixedResult['value'] = $aMenu['value'];
+        }
+
+        return $mixedResult;
     }
 
     public function getCode ()
@@ -97,6 +104,11 @@ class BxMessengerLotMenu extends BxBaseModTextMenu
 
         switch ($a['name']) {
             case 'settings':
+                // API: the app gets the talk info menu merged into one menu, no popup is needed.
+                if ($this->_bIsApi) {
+                    return false;
+                }
+
                 if ($this->_iContentId)
                     $this->addMarkers(array('lot_menu_id' => "lot-info-menu-{$this->_iContentId}"));
 

+ 13 - 0
modules/boonex/messenger/classes/BxMessengerModule.php

@@ -75,6 +75,7 @@ define('BX_MSG_TALK_TYPE_FILES', 'files');
 define('BX_MSG_TALK_TYPE_MR', 'mr');
 define('BX_MSG_TALK_TYPE_REPLIES', 'replies');
 define('BX_MSG_TALK_TYPE_SAVED', 'saved');
+define('BX_MSG_TALK_TYPE_FAVORITES', 'favorites');
 define('BX_MSG_TALK_TYPE_BROADCAST', 'broadcast');
 
 // Pages types
@@ -3305,6 +3306,10 @@ class BxMessengerModule extends BxBaseModGeneralModule
             case BX_MSG_TALK_TYPE_SAVED:
                 $aLotsList = $this->_oDb->getSavedJotInLots($this->_iProfileId, $aParams);
                 break;
+            case BX_MSG_TALK_TYPE_FAVORITES:
+                // Talks marked with a star by the viewer, the same filter as the star toggle on the site.
+                $aLotsList = $this->_oDb->getMyLots($this->_iProfileId, array_merge($aParams, ['star' => true]));
+                break;
             case BX_MSG_TALK_TYPE_GROUPS:
                 $aParams['group'] = $iId;
                 $aLotsList = $this->_oDb->getMyLots($this->_iProfileId, $aParams);
@@ -4027,6 +4032,14 @@ class BxMessengerModule extends BxBaseModGeneralModule
             'FindConvo' => 'BxMessengerServices',
             'LeaveConvo' => 'BxMessengerServices',
             'DeleteConvo' => 'BxMessengerServices',
+            'ClearConvo' => 'BxMessengerServices',
+            'MuteConvo' => 'BxMessengerServices',
+            'StarConvo' => 'BxMessengerServices',
+            'SaveJot' => 'BxMessengerServices',
+            'SearchConvoMessages' => 'BxMessengerServices',
+            'GetConvoFiles' => 'BxMessengerServices',
+            'GetConvoSettings' => 'BxMessengerServices',
+            'SaveConvoSettings' => 'BxMessengerServices',
             'GetConvo' => 'BxMessengerServices',
             'GetConvoUrl' => 'BxMessengerServices',
             'GetConvosList' => 'BxMessengerServices',

+ 394 - 6
modules/boonex/messenger/classes/BxMessengerServices.php

@@ -18,6 +18,9 @@ class BxMessengerServices extends BxDol
     protected $_oModule;
 
     protected $_iProfileId;
+    protected $_bAdministrator = null;
+    protected $_aLotMenuShort = null;
+    protected $_aStarredLots = null;
 
     public function __construct()
     {
@@ -45,7 +48,7 @@ class BxMessengerServices extends BxDol
                         'send_message' => $this->_oModule->_oConfig->isAllowedAction(BX_MSG_ACTION_SEND_MESSAGE, $this->_iProfileId) === true ? 1 : 0,
                     ]
                 ],
-                'menu' => $aMenuMain, 
+                'menu' => $aMenuMain,
                 'form' => ['data' => $this->serviceGetSendForm()],
             ], [
                 'ext' => [
@@ -141,7 +144,234 @@ class BxMessengerServices extends BxDol
 
         return ['code' => !$this->_oModule->_oDb->deleteLot($iLotId) ? 2 : 0];
     }
-    
+
+    /**
+     * Remove all messages from convo (lot) with defined ID
+     */
+    public function serviceClearConvo($sParams)
+    {
+        $aOptions = json_decode($sParams, true);
+
+        $iLotId = isset($aOptions['lot']) ? (int)$aOptions['lot'] : 0;
+
+        if(!$iLotId || !$this->_isAllowedToManage($iLotId)) {
+            return ['code' => 1, 'message' => _t('_bx_messenger_no_permissions')];
+        }
+
+        return ['code' => $this->_oModule->_oDb->clearLot($iLotId) === false ? 2 : 0];
+    }
+
+    /**
+     * Search messages of convo (lot) with defined ID by their text, newest first, by pages
+     */
+    public function serviceSearchConvoMessages($sParams)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aOptions = json_decode($sParams, true);
+
+        $iLotId = isset($aOptions['lot']) ? (int)$aOptions['lot'] : 0;
+        $sTerm = isset($aOptions['term']) ? trim((string)$aOptions['term']) : '';
+        $iStart = isset($aOptions['start']) ? max(0, (int)$aOptions['start']) : 0;
+        $iPerPage = 20;
+
+        if(!$iLotId || !$this->_iProfileId || !$this->_oModule->_oDb->getLotInfoById($iLotId)) {
+            return ['code' => 1, 'message' => _t('_bx_messenger_not_found')];
+        }
+
+        if(!$this->_oModule->_oDb->isParticipant($iLotId, $this->_iProfileId) && !$this->_isAdministrator()) {
+            return ['code' => 2, 'message' => _t('_bx_messenger_not_participant')];
+        }
+
+        $aResult = ['code' => 0, 'jots' => [], 'params' => ['start' => $iStart, 'per_page' => $iPerPage]];
+        if($sTerm === '') {
+            return $aResult;
+        }
+
+        // LIKE wildcards in the term are searched as plain characters.
+        $sTerm = addcslashes($sTerm, '%_\\');
+
+        $aJots = $this->_oModule->_oDb->findInHistory($iLotId, $sTerm, $iStart, $iPerPage, 'DESC', true);
+        foreach($aJots as $aJot) {
+            $aResult['jots'][] = [
+                'id' => (int)$aJot['message_id'],
+                'message' => $aJot[$CNF['FIELD_MESSAGE']],
+                'added' => (int)$aJot['message_added'],
+                'author_data' => BxDolProfile::getData($aJot['message_author']),
+            ];
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * Save message (jot) with defined ID to the viewer's saved messages, or remove it from there
+     */
+    public function serviceSaveJot($sParams)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aOptions = json_decode($sParams, true);
+
+        $iJotId = isset($aOptions['jot']) ? (int)$aOptions['jot'] : 0;
+
+        if(!$iJotId || !$this->_iProfileId || !($aJotInfo = $this->_oModule->_oDb->getJotById($iJotId))) {
+            return ['code' => 1, 'message' => _t('_bx_messenger_nothing_found')];
+        }
+
+        if(!$this->_isAvailable($aJotInfo[$CNF['FIELD_MESSAGE_FK']])) {
+            return ['code' => 2, 'message' => _t('_bx_messenger_not_participant')];
+        }
+
+        $bSaved = $this->_oModule->_oDb->isJotSaved($iJotId, $this->_iProfileId);
+        $mixedResult = $bSaved ? $this->_oModule->_oDb->deleteSavedJotItems($iJotId, $this->_iProfileId) : $this->_oModule->_oDb->saveJotItem($iJotId, $this->_iProfileId);
+        if($mixedResult === false) {
+            return ['code' => 3, 'message' => _t('_bx_messenger_nothing_has_been_saved')];
+        }
+
+        return ['code' => 0, 'value' => (int)!$bSaved, 'title' => _t($bSaved ? '_bx_messenger_jot_menu_save' : '_bx_messenger_jot_menu_remove_save')];
+    }
+
+    /**
+     * Turn notifications of convo (lot) with defined ID on / off for the viewer
+     */
+    public function serviceMuteConvo($sParams)
+    {
+        $aOptions = json_decode($sParams, true);
+
+        $iLotId = isset($aOptions['lot']) ? (int)$aOptions['lot'] : 0;
+
+        if(!$iLotId || !$this->_iProfileId || !$this->_oModule->_oDb->getLotInfoById($iLotId) || !$this->_isAvailable($iLotId)) {
+            return ['code' => 1, 'message' => _t('_bx_messenger_not_found')];
+        }
+
+        $bMuted = (bool)$this->_oModule->_oDb->muteLot($iLotId, $this->_iProfileId);
+
+        return ['code' => 0, 'value' => (int)$bMuted, 'title' => _t($bMuted ? '_bx_messenger_lots_menu_mute_info_on' : '_bx_messenger_lots_menu_mute_info_off')];
+    }
+
+    /**
+     * Mark convo (lot) with defined ID as important (star) / not important for the viewer
+     */
+    public function serviceStarConvo($sParams)
+    {
+        $aOptions = json_decode($sParams, true);
+
+        $iLotId = isset($aOptions['lot']) ? (int)$aOptions['lot'] : 0;
+
+        if(!$iLotId || !$this->_iProfileId || !$this->_oModule->_oDb->getLotInfoById($iLotId) || !$this->_isAvailable($iLotId)) {
+            return ['code' => 1, 'message' => _t('_bx_messenger_not_found')];
+        }
+
+        $bStarred = (bool)$this->_oModule->_oDb->starLot($iLotId, $this->_iProfileId);
+
+        return ['code' => 0, 'value' => (int)$bStarred, 'title' => _t(!$bStarred ? '_bx_messenger_lots_menu_star_on' : '_bx_messenger_lots_menu_star_off')];
+    }
+
+    /**
+     * Get files attached to messages of convo (lot) with defined ID, newest first, by pages
+     */
+    public function serviceGetConvoFiles($sParams)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aOptions = json_decode($sParams, true);
+
+        $iLotId = isset($aOptions['lot']) ? (int)$aOptions['lot'] : 0;
+        $iStart = isset($aOptions['start']) ? max(0, (int)$aOptions['start']) : 0;
+        $iPerPage = (int)$CNF['PARAM_DEFAULT_TALK_FILES_NUM'];
+
+        if(!$iLotId || !$this->_oModule->_oDb->getLotInfoById($iLotId)) {
+            return ['code' => 1, 'message' => _t('_bx_messenger_not_found')];
+        }
+
+        if(!$this->_iProfileId || !$this->_oModule->_oDb->isParticipant($iLotId, $this->_iProfileId)) {
+            return ['code' => 2, 'message' => _t('_bx_messenger_not_participant')];
+        }
+
+        $sStorage = $CNF['OBJECT_STORAGE'];
+        $oStorage = new BxMessengerStorage($sStorage);
+        // File URLs come from the storage engine object, BxMessengerStorage only checks types.
+        $oStorageEngine = BxDolStorage::getObjectInstance($sStorage);
+
+        $aFiles = [];
+        foreach($this->_oModule->_oDb->getLotFiles($iLotId, $iStart, $iPerPage) as $aFile) {
+            $iFileId = (int)$aFile[$CNF['FIELD_ST_ID']];
+            $bImage = $oStorage->isImageFile($aFile[$CNF['FIELD_ST_TYPE']]);
+
+            $aFiles[] = [
+                'id' => $iFileId,
+                'name' => $aFile[$CNF['FIELD_ST_NAME']],
+                'mime_type' => $aFile[$CNF['FIELD_ST_TYPE']],
+                'size' => isset($aFile['size']) ? (int)$aFile['size'] : 0,
+                'added' => (int)$aFile[$CNF['FIELD_ST_ADDED']],
+                'jot_id' => (int)$aFile[$CNF['FIELD_ST_JOT']],
+                'author_data' => BxDolProfile::getData($aFile[$CNF['FIELD_ST_AUTHOR']]),
+                'image' => $bImage ? bx_api_get_image($sStorage, $iFileId) : null,
+                'url' => $oStorageEngine ? $oStorageEngine->getFileUrlById($iFileId) : '',
+            ];
+        }
+
+        return [
+            'code' => 0,
+            'files' => $aFiles,
+            'params' => [
+                'start' => $iStart,
+                'per_page' => $iPerPage,
+                'total' => $this->_oModule->_oDb->getLotFilesCount($iLotId)
+            ]
+        ];
+    }
+
+    /**
+     * Get settings (allowed actions) of convo (lot) with defined ID
+     */
+    public function serviceGetConvoSettings($sParams)
+    {
+        $aOptions = json_decode($sParams, true);
+
+        $iLotId = isset($aOptions['lot']) ? (int)$aOptions['lot'] : 0;
+
+        if(!$iLotId || !$this->_oModule->_oDb->getLotInfoById($iLotId)) {
+            return ['code' => 1, 'message' => _t('_bx_messenger_not_found')];
+        }
+
+        if(!$this->_isAllowedToManage($iLotId)) {
+            return ['code' => 2, 'message' => _t('_bx_messenger_lot_can_change_settings')];
+        }
+
+        return array_merge(['code' => 0, 'title' => _t('_bx_messenger_lot_options_title')], $this->_oModule->_oTemplate->getLotSettingsOptions($iLotId));
+    }
+
+    /**
+     * Save settings (allowed actions) of convo (lot) with defined ID
+     */
+    public function serviceSaveConvoSettings($sParams)
+    {
+        $aOptions = json_decode($sParams, true);
+
+        $iLotId = isset($aOptions['lot']) ? (int)$aOptions['lot'] : 0;
+
+        if(!$iLotId || !$this->_oModule->_oDb->getLotInfoById($iLotId)) {
+            return ['code' => 1, 'message' => _t('_bx_messenger_not_found')];
+        }
+
+        if(!$this->_isAllowedToManage($iLotId)) {
+            return ['code' => 2, 'message' => _t('_bx_messenger_lot_can_change_settings')];
+        }
+
+        // Only known options can be saved.
+        $aSettings = $this->_oModule->_oTemplate->getLotSettingsOptions($iLotId);
+        $aValues = isset($aOptions['options']) && is_array($aOptions['options']) ? $aOptions['options'] : [];
+        $aValues = array_values(array_intersect(array_column($aSettings['options'], 'key'), $aValues));
+
+        if($this->_oModule->_oDb->saveLotSettings($iLotId, $aValues) === false) {
+            return ['code' => 3, 'message' => _t('_bx_messenger_nothing_has_been_saved')];
+        }
+
+        return ['code' => 0, 'values' => $aValues];
+    }
+
     /**
      * Get convo (lot) info with defined ID
      */
@@ -216,6 +446,7 @@ class BxMessengerServices extends BxDol
         if(empty($aList))
             return $aResult;
 
+        $this->_prefetchStarredLots($aData['list']);
         foreach($aList as $iKey => $aItem)
             $aResult[] = $this->_unitLot($aData['list'][$iKey], $aItem);
 
@@ -320,6 +551,9 @@ class BxMessengerServices extends BxDol
             $sStorage = $CNF['OBJECT_STORAGE'];
             $oStorage = new BxMessengerStorage($sStorage);
 
+            // Messages of the page saved by the viewer, one query for the page.
+            $aSavedJots = array_map('intval', $this->_oModule->_oDb->getSavedJotsByIds($this->_iProfileId, array_column($mixedContent, $CNF['FIELD_MESSAGE_ID'])));
+
             foreach($mixedContent as &$aJot) {
                 $iJotId = $aJot[$CNF['FIELD_MESSAGE_ID']];
 
@@ -354,7 +588,8 @@ class BxMessengerServices extends BxDol
                     'reactions' => $aReactions,
                     'menu' => $oMenu->getCodeAPI(),
                     'files' => $aFiles,
-                    'reply_message' => $sReplyMessage
+                    'reply_message' => $sReplyMessage,
+                    'saved' => in_array((int)$iJotId, $aSavedJots) ? 1 : 0
                 ]);
             }
         }
@@ -553,6 +788,64 @@ class BxMessengerServices extends BxDol
     }
 
     public function serviceSearchUsers($sParams)
+    {
+        $aOptions = json_decode($sParams, true);
+        if (!$sParams || !isset($aOptions['term'])) {
+            return ['code' => 1];
+        }
+
+        // Friends only mode: the module's own search among the viewer's friends.
+        if ($this->_oModule->_oConfig->CNF['USE-FRIENDS-ONLY-MODE']) {
+            return $this->_searchUsersByModule($sParams);
+        }
+
+        $sTerm = trim((string)$aOptions['term']);
+        if ($sTerm === '') {
+            return [];
+        }
+
+        $aExcept = isset($aOptions['except']) && is_array($aOptions['except']) ? array_map('intval', $aOptions['except']) : [];
+        $iLimit = 10;
+
+        // Names are searched in each profile module directly (its quick search fields),
+        // so the result doesn't depend on the site's global search settings.
+        $aProfileIds = [];
+        $aModules = BxDolService::call('system', 'get_profiles_modules', [], 'TemplServiceProfiles');
+        foreach ((array)$aModules as $aModule) {
+            if (!BxDolService::call($aModule['name'], 'act_as_profile') || !($oModule = BxDolModule::getInstance($aModule['name']))) {
+                continue;
+            }
+
+            foreach ((array)$oModule->_oDb->searchByTerm($sTerm, $iLimit * 2) as $aRow) {
+                if (!empty($aRow['profile_id'])) {
+                    $aProfileIds[] = (int)$aRow['profile_id'];
+                }
+            }
+        }
+
+        $aUsers = [];
+        foreach (array_unique($aProfileIds) as $iProfileId) {
+            if ($iProfileId == $this->_iProfileId || in_array($iProfileId, $aExcept)) {
+                continue;
+            }
+
+            if (!($oProfile = BxDolProfile::getInstance($iProfileId)) || $oProfile->checkAllowedProfileContact() !== CHECK_ACTION_RESULT_ALLOWED) {
+                continue;
+            }
+
+            $aUsers[] = BxDolProfile::getData($iProfileId);
+            if (count($aUsers) >= $iLimit) {
+                break;
+            }
+        }
+
+        return $aUsers;
+    }
+
+    /**
+     * Users search with the module's search (friends only mode).
+     */
+    protected function _searchUsersByModule($sParams)
     {
         $aOptions = json_decode($sParams, true);
         $aResult = ['code' => 1];
@@ -579,11 +872,36 @@ class BxMessengerServices extends BxDol
 
         $sTerm = isset($aOptions['term']) ? $aOptions['term'] : '';
         $iStarred = isset($aOptions['starred']) ? (int)$aOptions['starred'] : 0;
+        // The list section the search runs in: the results stay inside it.
+        $sGroup = isset($aOptions['group']) ? $aOptions['group'] : '';
+
+        $aSearchParams = [
+            'term' => $sTerm,
+            'star' => (bool)$iStarred || $sGroup === BX_MSG_TALK_TYPE_FAVORITES,
+            // The app searches talks by their titles and participants' names, not by messages.
+            'search_criteria' => [BX_SEARCH_CRITERIA_TITLES, BX_SEARCH_CRITERIA_PARTS]
+        ];
+        if($sGroup === BX_MSG_TALK_TYPE_DIRECT) {
+            $aSearchParams['type'] = BX_IM_TYPE_PRIVATE;
+        }
 
         $aJots = [];
-        $aLots = $this->_oModule->_oDb->getMyLots($this->_iProfileId, ['term' => $sTerm, 'star' => (bool)$iStarred], $aJots);
-        if(empty($aLots) || !is_array($aLots))
+        $aLots = $this->_oModule->_oDb->getMyLots($this->_iProfileId, $aSearchParams, $aJots);
+        if(empty($aLots) || !is_array($aLots)) {
             return [];
+        }
+
+        // Saved messages section: only talks with messages saved by the viewer.
+        if($sGroup === BX_MSG_TALK_TYPE_SAVED) {
+            $CNF = &$this->_oModule->_oConfig->CNF;
+            $aSavedLotIds = array_flip($this->_oModule->_oDb->getSavedLotIds($this->_iProfileId));
+            $aLots = array_values(array_filter($aLots, function($aLot) use ($CNF, $aSavedLotIds) {
+                return isset($aSavedLotIds[(int)$aLot[$CNF['FIELD_ID']]]);
+            }));
+            if(empty($aLots)) {
+                return [];
+            }
+        }
 
         $aResult = [
             'lots' => [],
@@ -591,6 +909,7 @@ class BxMessengerServices extends BxDol
         ];
 
         $aItems = $this->_oModule->_oTemplate->getLotsPreview($this->_iProfileId, $aLots);
+        $this->_prefetchStarredLots($aLots);
         foreach($aItems as $iKey => $aItem)
             $aResult['lots'][] = $this->_unitLot($aLots[$iKey], $aItem);
 
@@ -683,7 +1002,7 @@ class BxMessengerServices extends BxDol
                 if(!$iPartId)
                     continue;
 
-                $aParticipants[] = BxDolProfile::getData($iPartId);
+                $aParticipants[] = BxDolProfile::getData($iPartId, ['with_online' => true]);
             }
         }
 
@@ -708,6 +1027,10 @@ class BxMessengerServices extends BxDol
             'date' => $aItem['bx_if:timer']['content']['time'],
             'id' => $this->_normalizeConvoHash($aLot[$CNF['FIELD_HASH']]),
             'id2' => $aItem[$CNF['FIELD_ID']],
+            // Marked with a star (favorite) by the viewer.
+            'starred' => $this->_isStarredLot((int)$aItem[$CNF['FIELD_ID']]) ? 1 : 0,
+            // Conversation actions (gear menu): the app requests the items on demand with system/get_menu.
+            'menu' => $this->_getLotMenuShort((int)$aItem[$CNF['FIELD_ID']]),
             'unread' => $aItem['count'],
             'total_messages' => $this->_oModule->_oDb->getJotsNumber($aItem[$CNF['FIELD_ID']], 0)
         ];
@@ -725,6 +1048,71 @@ class BxMessengerServices extends BxDol
             $oSockets->sendEvent('bx', 'messenger', $sAction, $aData);
     }
 
+    /**
+     * Talks of a list marked with a star by the viewer, loaded with one query for the whole list.
+     */
+    protected function _prefetchStarredLots($aLots)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aLotIds = [];
+        foreach((array)$aLots as $aLot) {
+            if(!empty($aLot[$CNF['FIELD_ID']])) {
+                $aLotIds[] = (int)$aLot[$CNF['FIELD_ID']];
+            }
+        }
+
+        $this->_aStarredLots = array_map('intval', $this->_oModule->_oDb->getStarredLots($this->_iProfileId, $aLotIds));
+    }
+
+    protected function _isStarredLot($iLotId)
+    {
+        if($this->_aStarredLots !== null) {
+            return in_array((int)$iLotId, $this->_aStarredLots);
+        }
+
+        return (bool)$this->_oModule->_oDb->isStarred($iLotId, $this->_iProfileId);
+    }
+
+    /**
+     * Short code of the talk actions menu (object, title, params), items are loaded on demand.
+     */
+    protected function _getLotMenuShort($iLotId)
+    {
+        if($this->_aLotMenuShort === null) {
+            $CNF = &$this->_oModule->_oConfig->CNF;
+
+            $oMenu = BxTemplMenu::getObjectInstance($CNF['OBJECT_MENU_TALK_INFO_MENU']);
+            $this->_aLotMenuShort = $oMenu ? $oMenu->getShortCodeAPI() : [];
+        }
+
+        if(empty($this->_aLotMenuShort)) {
+            return [];
+        }
+
+        return array_merge($this->_aLotMenuShort, ['params' => ['lot' => (int)$iLotId]]);
+    }
+
+    /**
+     * Talk author or a member who can administrate talks.
+     */
+    protected function _isAllowedToManage($iLotId)
+    {
+        return $this->_iProfileId && ($this->_oModule->_oDb->isAuthor($iLotId, $this->_iProfileId) || $this->_isAdministrator());
+    }
+
+    /**
+     * Member can administrate talks. Checked once per request: the ACL check counts the action.
+     */
+    protected function _isAdministrator()
+    {
+        if($this->_bAdministrator === null) {
+            $this->_bAdministrator = $this->_iProfileId && $this->_oModule->_oConfig->isAllowedAction(BX_MSG_ACTION_ADMINISTRATE_TALKS, $this->_iProfileId) === true;
+        }
+
+        return $this->_bAdministrator;
+    }
+
     protected function _isAvailable($iLotId)
     {
         if(!$iLotId)

+ 28 - 13
modules/boonex/messenger/classes/BxMessengerTemplate.php

@@ -2506,6 +2506,33 @@ class BxMessengerTemplate extends BxBaseModGeneralTemplate
         return BxTemplFunctions::getInstance()->transBox('bx-messenger-vc-call', $sContent);
     }
 
+    /**
+     * Talk settings: available options and the selected ones (all of them when nothing is saved yet).
+     * @param int $iLotId
+     * @return array ['options' => [['key' => ..., 'value' => ...], ...], 'values' => [...]]
+     */
+    public function getLotSettingsOptions($iLotId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aSettingsOptions = [];
+        $aSelectedValues = [];
+        $aLotSettings = $this->_oDb->getLotSettings($iLotId);
+        foreach ($CNF['LOT_OPTIONS'] as $sValue) {
+            $aSettingsOptions[] = ['key' => $sValue, 'value' => _t("_bx_messenger_lot_options_item_{$sValue}")];
+            if ($aLotSettings === false || in_array($sValue, $aLotSettings)) {
+                $aSelectedValues[] = $sValue;
+            }
+        }
+
+        bx_alert($this->_oConfig->getObject('alert'), 'talk_settings_before', $iLotId, $iLotId, [
+            'options' => &$aSettingsOptions,
+            'values' => &$aSelectedValues
+        ]);
+
+        return ['options' => $aSettingsOptions, 'values' => $aSelectedValues];
+    }
+
     public function getLotSettingsForm($iLotId){
         $CNF = &$this->_oConfig->CNF;
         $aLotInfo = $this->_oDb->getLotInfoById($iLotId);
@@ -2514,19 +2541,7 @@ class BxMessengerTemplate extends BxBaseModGeneralTemplate
             $sContent = MsgBox(_t('_Empty'));
         else
         {
-            $aSettingsOptions = [];
-            $aSelectedValues = [];
-            $aLotSettings = $this->_oDb->getLotSettings($iLotId);
-            foreach ($CNF['LOT_OPTIONS'] as $sValue) {
-                $aSettingsOptions[] = ['key' => $sValue, 'value' => _t("_bx_messenger_lot_options_item_{$sValue}")];
-                if ($aLotSettings === false || in_array($sValue, $aLotSettings))
-                    $aSelectedValues[] = $sValue;
-            }
-
-            bx_alert($this->_oConfig->getObject('alert'), 'talk_settings_before', $iLotId, $iLotId, [
-                'options' => &$aSettingsOptions,
-                'values' => &$aSelectedValues
-            ]);
+            ['options' => $aSettingsOptions, 'values' => $aSelectedValues] = $this->getLotSettingsOptions($iLotId);
 
             $aForm = [
                 'form_attrs' => [

+ 2 - 1
modules/boonex/messenger/install/langs/en.xml

@@ -304,7 +304,8 @@ name="_bx_ntfs_alert_action_got_broadcast_ntfs_personal"><![CDATA[Broadcast mess
 	<string name="_bx_messenger_nav_menu_item_title_direct"><![CDATA[Direct]]></string>
 	<string name="_bx_messenger_nav_menu_item_title_files"><![CDATA[Files]]></string>
 	<string name="_bx_messenger_nav_menu_item_title_replies"><![CDATA[Replies]]></string>
-	<string name="_bx_messenger_nav_menu_item_title_saved"><![CDATA[Bookmarked]]></string>
+	<string name="_bx_messenger_nav_menu_item_title_saved"><![CDATA[Saved messages]]></string>
+	<string name="_bx_messenger_nav_menu_item_title_favorites"><![CDATA[Favorite chats]]></string>
 	<string name="_bx_messenger_nav_menu_item_title_mr"><![CDATA[Mentions & Reactions]]></string>
 	<string name="_bx_messenger_menu_item_title_parent_lot"><![CDATA[Open original message]]></string>
 	<string name="_bx_messenger_jot_server_jwt"><![CDATA[Secret word for JWT Token Authentication with Jot Server. Set the same <b>secret</b> word in the config/config.json file of the Jot Server to enable this option. This allows connections to Jot Server only from authorized sites. Leave it empty if you don't want to use JWT tokens with Jot Server.]]></string>

+ 2 - 1
modules/boonex/messenger/install/langs/ru.xml

@@ -267,7 +267,8 @@
 	<string name="_bx_messenger_nav_menu_item_title_threads"><![CDATA[Обсуждения]]></string>
 	<string name="_bx_messenger_nav_menu_item_title_direct"><![CDATA[Прямые разговоры]]></string>
 	<string name="_bx_messenger_nav_menu_item_title_files"><![CDATA[Файлы]]></string>
-	<string name="_bx_messenger_nav_menu_item_title_saved"><![CDATA[Сохраненные]]></string>
+	<string name="_bx_messenger_nav_menu_item_title_saved"><![CDATA[Сохраненные сообщения]]></string>
+	<string name="_bx_messenger_nav_menu_item_title_favorites"><![CDATA[Избранные чаты]]></string>
 	<string name="_bx_messenger_nav_menu_item_title_reply"><![CDATA[Ответы]]></string>
 	<string name="_bx_messenger_nav_menu_item_title_mr"><![CDATA[Реакции и Упоминания]]></string>
 	<string name="_bx_messenger_menu_item_title_parent_lot"><![CDATA[Open original message]]></string>

+ 1 - 0
modules/boonex/messenger/install/sql/enable-app.sql

@@ -13,3 +13,4 @@ UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_messenger_nav_me
 UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_messenger_nav_menu' AND `module`='bx_messenger' AND `name`='threads';
 UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_messenger_nav_menu' AND `module`='bx_messenger' AND `name`='inbox';
 UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_messenger_nav_menu' AND `module`='bx_messenger' AND `name`='direct';
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='bx_messenger_nav_menu' AND `module`='bx_messenger' AND `name`='favorites';

+ 2 - 1
modules/boonex/messenger/install/sql/enable.sql

@@ -311,7 +311,8 @@ INSERT INTO `sys_menu_items`(`set_name`, `module`, `name`, `title_system`, `titl
 (CONCAT(@sName, '_nav_menu'), @sName, 'threads', '_bx_messenger_nav_menu_item_title_threads', '_bx_messenger_nav_menu_item_title_threads', 'javascript:void(0);', 'oMessenger.loadTalksList(this, {group : ''threads''});', '_self', 'comments col-blue2', '', 2147483647, 1, 0, 2),
 (CONCAT(@sName, '_nav_menu'), @sName, 'reply', '_bx_messenger_nav_menu_item_title_replies', '_bx_messenger_nav_menu_item_title_replies', 'javascript:void(0);', 'oMessenger.loadTalksList(this, {group : ''replies''});', '_self', 'reply', '', 2147483647, 1, 0, 3),
 (CONCAT(@sName, '_nav_menu'), @sName, 'mentions_reactions', '_bx_messenger_nav_menu_item_title_mr', '_bx_messenger_nav_menu_item_title_mr', 'javascript:void(0)', 'oMessenger.loadTalksList(this, {group : ''mr''});', '_self', 'at col-green2', '', 2147483647, 1, 0, 4),
-(CONCAT(@sName, '_nav_menu'), @sName, 'saved', '_bx_messenger_nav_menu_item_title_saved', '_bx_messenger_nav_menu_item_title_saved', 'javascript:void(0);', 'oMessenger.loadTalksList(this, {group : ''saved''});', '_self', 'bookmark col-red1', '', 2147483647, 1, 0, 5);
+(CONCAT(@sName, '_nav_menu'), @sName, 'saved', '_bx_messenger_nav_menu_item_title_saved', '_bx_messenger_nav_menu_item_title_saved', 'javascript:void(0);', 'oMessenger.loadTalksList(this, {group : ''saved''});', '_self', 'bookmark col-red1', '', 2147483647, 1, 0, 5),
+(CONCAT(@sName, '_nav_menu'), @sName, 'favorites', '_bx_messenger_nav_menu_item_title_favorites', '_bx_messenger_nav_menu_item_title_favorites', 'javascript:void(0);', '', '_self', 'star col-red2', '', 2147483647, 0, 0, 6);
 
 -- MENU: Groups Menu
 INSERT INTO `sys_objects_menu`(`object`, `title`, `set_name`, `module`, `template_id`, `deletable`, `active`, `override_class_name`, `override_class_file`) VALUES

+ 23 - 5
neo/packages/app/components/elements/chat/parts/convos-list-item.tsx

@@ -4,6 +4,7 @@ import { Text } from 'app/design/typography';
 import { View, Row, Pressable } from 'app/design/view';
 import Profile from 'app/ui/molecules/profile/profile';
 import Time from 'app/ui/atoms/time';
+import { Icon } from 'app/ui/atoms/icon';
 
 const isWeb = Platform.OS === 'web';
 
@@ -19,6 +20,10 @@ type ConvosListItemProps = {
     /** "+N" badge on the avatar. */
     extraCount?: number;
     selected?: boolean;
+    /** Star badge on the avatar (favorite conversation). */
+    starred?: boolean;
+    /** Green dot on the avatar: the other person is online. */
+    online?: boolean;
     onPress: () => void;
 };
 
@@ -39,6 +44,8 @@ export default function ConvosListItem({
     unread = 0,
     extraCount = 0,
     selected = false,
+    starred = false,
+    online = false,
     onPress,
 }: ConvosListItemProps) {
     // Native has no :active for the inner Row, so track the press to show the
@@ -64,6 +71,14 @@ export default function ConvosListItem({
                             {title}
                         </Text>
                         {time ? <Time className="text-sm text-muted-foreground whitespace-nowrap" ts={time} /> : null}
+                        {/* Unread count sits on the title line, after the time. */}
+                        {unread > 0 && !selected ? (
+                            <View className="flex-none bg-primary rounded-full min-w-5 h-5 items-center justify-center px-1.5">
+                                <Text className="text-xs leading-4 text-primary-foreground font-semibold">
+                                    {unread}
+                                </Text>
+                            </View>
+                        ) : null}
                     </Row>
                     <View>
                         <Text className="block text-sm text-secondary-foreground web:group-hover:text-foreground line-clamp-1 truncate overflow-hidden max-w-full" numberOfLines={1}>
@@ -71,11 +86,14 @@ export default function ConvosListItem({
                         </Text>
                     </View>
                 </View>
-                {unread > 0 && !selected ? (
-                    <View className="flex-none bg-primary rounded-full mb-auto mt-1 h-min min-w-5 min-h-5 items-center justify-center px-1.5">
-                        <Text className="text-xs text-primary-foreground font-semibold">
-                            {unread}
-                        </Text>
+
+                {online ? (
+                    <View className="absolute bottom-2 start-14 sm:start-12 w-3.5 h-3.5 rounded-full bg-green border-2 border-card" accessibilityLabel="online" />
+                ) : null}
+
+                {starred ? (
+                    <View className="absolute bottom-1 start-3 sm:start-1 bg-card rounded-full p-0.5">
+                        <Icon icon="Star" size={14} className="text-primary" />
                     </View>
                 ) : null}
 

+ 13 - 6
neo/packages/app/components/elements/chat/parts/headers.tsx

@@ -53,7 +53,8 @@ export function useDebouncedSearch(onSearch?: (term: string) => void) {
     return { value, setValue, reset };
 }
 
-export function SearchInput({ value, onChangeText }: { value: string; onChangeText: (text: string) => void }) {
+/** `autoFocus`: focus the field when it appears (search opened by a button). */
+export function SearchInput({ value, onChangeText, autoFocus = false }: { value: string; onChangeText: (text: string) => void; autoFocus?: boolean }) {
     const { t } = useTranslation();
     return (
         <Input
@@ -63,6 +64,7 @@ export function SearchInput({ value, onChangeText }: { value: string; onChangeTe
             placeholder={t('Search') + '...'}
             value={value}
             onChangeText={onChangeText}
+            autoFocus={autoFocus}
         />
     );
 }
@@ -134,7 +136,7 @@ export function ConvosListHeader({
             <Row className="min-h-16 items-center gap-2 px-4">
                 {showSearch ? (
                     <View className="flex-1 min-w-0">
-                        <SearchInput value={searchValue} onChangeText={onChangeSearch} />
+                        <SearchInput value={searchValue} onChangeText={onChangeSearch} autoFocus />
                     </View>
                 ) : (
                     <Text numberOfLines={1} className="flex-1 min-w-0 font-bold text-card-foreground text-2xl tracking-tight font-main">
@@ -181,13 +183,18 @@ export function ConvosListHeader({
 export const PANEL_HEADER_HEIGHT = 64;
 
 /** Desktop: title + optional actions row at the top of the content panel (conversation, agents). */
-export function PanelHeader({ title, actions }: { title: string; actions?: ReactNode }) {
+/** `search` (a search field) takes the title's place while set. */
+export function PanelHeader({ title, actions, search }: { title: string; actions?: ReactNode; search?: ReactNode }) {
     return (
         <Row className="h-16 items-center max-w-full">
             <Row className="flex-1 min-w-0 items-center gap-3 px-4 overflow-hidden">
-                <Text numberOfLines={1} className="flex-1 min-w-0 font-bold text-card-foreground text-lg sm:text-xl tracking-tight">
-                    {title}
-                </Text>
+                {search ? (
+                    <View className="flex-1 min-w-0">{search}</View>
+                ) : (
+                    <Text numberOfLines={1} className="flex-1 min-w-0 font-bold text-card-foreground text-lg sm:text-xl tracking-tight">
+                        {title}
+                    </Text>
+                )}
             </Row>
             {actions ? (
                 <View className="shrink-0 items-center justify-center pe-4">

+ 8 - 5
neo/packages/app/components/elements/chat/parts/message-item.tsx

@@ -12,6 +12,8 @@ type MessageItemProps = {
     children: ReactNode;
     /** Row under the card (Reply / reactions). */
     footer?: ReactNode;
+    /** Small marks before the time in the card header (e.g. saved). */
+    badges?: ReactNode;
 };
 
 /**
@@ -20,7 +22,7 @@ type MessageItemProps = {
  * (Reply / reactions). Shared by the messenger and the agent chat so both
  * transcripts look the same.
  */
-export default function MessageItem({ author, time, children, footer }: MessageItemProps) {
+export default function MessageItem({ author, time, children, footer, badges }: MessageItemProps) {
     return (
         <View className="w-full px-4 mt-3">
             <Row className="gap-2">
@@ -29,10 +31,11 @@ export default function MessageItem({ author, time, children, footer }: MessageI
                     <View className="bg-card/60 shadow-card-outline dark:shadow-card-outline-deep rounded-xl px-3">
                         <Row className="items-center w-full justify-between gap-1 mb-0.5 pt-2">
                             <Profile {...author} displayType="unit_wo_image" />
-                            {time ? (
-                                <View>
-                                    <Time className="text-muted-foreground text-sm" ts={time} />
-                                </View>
+                            {badges || time ? (
+                                <Row className="items-center gap-1.5">
+                                    {badges}
+                                    {time ? <Time className="text-muted-foreground text-sm" ts={time} /> : null}
+                                </Row>
                             ) : null}
                         </Row>
                         {children}

+ 2 - 2
neo/packages/app/components/elements/messenger.js

@@ -21,7 +21,7 @@ export default function MessengerEl(props) {
         defaultConvoId = aUrl[2];
 
     const menuDefaultList = useMemo(() => {
-        return props.data.menu.items.filter(item => ['inbox', 'direct'].includes(item.name));
+        return props.data.menu.items.filter(item => ['inbox', 'direct', 'favorites', 'saved'].includes(item.name));
     }, [props]);
 
     const [menu, setMenu] = useState(() => {
@@ -40,7 +40,7 @@ export default function MessengerEl(props) {
 
         const requestId = ++fetchRequestId.current;
         const request_url = term
-            ? '/api.php?r=bx_messenger/search_lots/Services&params=' + JSON.stringify({ term })
+            ? '/api.php?r=bx_messenger/search_lots/Services&params=' + JSON.stringify({ term, group: menuItem })
             : '/api.php?r=bx_messenger/get_convos_list/Services&params[]=' + JSON.stringify({ group: menuItem, count: 0 });
 
         try {

+ 334 - 121
neo/packages/app/components/elements/messenger/parts/common.js → neo/packages/app/components/elements/messenger/parts/common.tsx

@@ -1,19 +1,19 @@
 import { Text } from 'app/design/typography'
 import { View, Row } from 'app/design/view'
 import { fetcher } from 'app/lib/fetcher';
-import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { useState, useEffect, useRef, useCallback, useMemo, type ComponentType, type ReactNode } from 'react';
 import { appSetting, isNativeTabsEnabled } from 'app/lib/util'
-import { Platform } from 'react-native'
+import { Platform, type ViewStyle } from 'react-native'
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
 import { playCue } from 'app/lib/sound'
 import { useCurrentUser } from 'app/context/user';
 import { subscribe } from 'app/ui/atoms/socket';
 import { KbStickyView } from 'app/ui/atoms/kb-avoiding-view';
-import CreateConvo, { CreateConvoButton } from 'app/components/elements/messenger/parts/new-convo';
+import CreateConvo, { CreateConvoButton as CreateConvoButtonJs } from 'app/components/elements/messenger/parts/new-convo';
 import Msg from 'app/ui/molecules/dialogs/msg';
 import { useBottomSheetData } from 'app/context/bottomsheet';
 import Profile from 'app/ui/molecules/profile/profile'
-import ElementMsg from 'app/components/elements/msg';
+import ElementMsgJs from 'app/components/elements/msg';
 import emitter, { EVENTS } from 'app/context/emitter';
 import { getWindowSafeAreaInsets, useCurrentTabPath, useIsFocused, useRouter, useSafeAreaInsets } from 'app/lib/hooks/router'
 import { useIsPushedScreen } from 'app/context/tab-chrome';
@@ -22,13 +22,17 @@ import { useTranslation } from 'react-i18next'
 import ChatPanels from 'app/components/elements/chat/parts/chat-panels'
 import { useIsDesktop } from 'app/context/measure';
 import { getNativeTabBarHeight } from 'app/components/nav/tabs/tab-menu';
-import { useFooterHeight, useSetPageBottomBlur } from 'app/context/jotai/layout';
-import { PageHeaderOptions } from 'app/ui/molecules/header/options';
+import { useFooterHeight, useHeaderHeight, useSetPageBottomBlur } from 'app/context/jotai/layout';
+import { PageHeaderOptions as PageHeaderOptionsJs } from 'app/ui/molecules/header/options';
 import ConvosList from 'app/components/elements/chat/parts/convos-list';
 import ItemConvo from 'app/components/elements/messenger/parts/item-convo';
 import JotsPanel, { ChatEmptyState } from 'app/components/elements/messenger/parts/jots-panel';
-import Composer from 'app/components/elements/messenger/parts/composer';
-import { MobileListHeader, MobileChatHeader, ConvoActionsMenu } from 'app/components/elements/messenger/parts/headers';
+import ComposerJs from 'app/components/elements/messenger/parts/composer';
+import { MobileListHeader, MobileChatHeader, ConvoActionsMenu, ConvoSearchButton, ConvoSearchField } from 'app/components/elements/messenger/parts/headers';
+import Confirm from 'app/ui/molecules/dialogs/confirm';
+import ConvoSettings from 'app/components/elements/messenger/parts/convo-settings';
+import ConvoFiles from 'app/components/elements/messenger/parts/convo-files';
+import ConvoSearchResults from 'app/components/elements/messenger/parts/convo-search';
 import {
     PanelHeader,
     PANEL_HEADER_HEIGHT,
@@ -37,28 +41,62 @@ import {
     CHAT_OVERLAY_COMPOSER_FADE,
 } from 'app/components/elements/chat/parts/headers';
 import { EdgeBlurView, edgeBlurConfig } from 'app/ui/atoms/edge-blur';
+import type { AddButtons, ConvoAction, Convo, Jot, JotsState, MessengerMenuItem } from 'app/components/elements/messenger/parts/messenger.types';
 
 const isWeb = Platform.OS === 'web';
 
 // Keep in sync with tabBarStyle.height in app/components/nav/tabs/native-tabs.js
 const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 52 : 56
 
+// Untyped JS components: their inferred props mark every prop as required.
+const ConfirmDialog = Confirm as unknown as ComponentType<Record<string, any>>;
+const MsgDialog = Msg as unknown as ComponentType<Record<string, any>>;
+const CreateConvoForm = CreateConvo as unknown as ComponentType<Record<string, any>>;
+const CreateConvoButton = CreateConvoButtonJs as unknown as ComponentType<Record<string, any>>;
+const ElementMsg = ElementMsgJs as unknown as ComponentType<Record<string, any>>;
+const PageHeaderOptions = PageHeaderOptionsJs as unknown as ComponentType<Record<string, any>>;
+const Composer = ComposerJs as unknown as ComponentType<Record<string, any>>;
+
+type LoosePayload = Record<string, any>;
+
+// expo-router on native, the Next router on web: only the native branch pushes tab pages.
+type MessengerRouter = { push: (href: unknown) => void; back: () => void; canGoBack?: () => boolean };
+
+type MessengerProps = {
+    defaultConvoId: string;
+    selectedMenu: string;
+    convos: { data?: Convo[]; menu?: string } | false;
+    fetchConvos: (term?: string) => void;
+    data: LoosePayload;
+    pageData?: LoosePayload;
+    onSave: (data: LoosePayload) => void;
+    addButtons?: AddButtons;
+    menuItems?: MessengerMenuItem[];
+    menuIndex: number;
+    onMenuChange: (index: number) => void;
+};
+
 /**
  * Messenger root: owns conversation selection, panel visibility, URL/history
  * sync and the injected page header. Composes ConvosList / JotsPanel /
  * Composer (small screens: stacked list ↔ chat; web desktop: resizable
  * two-column split inside the UNA block).
  */
-export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchConvos, data, pageData, onSave, addButtons, menuItems, menuIndex, onMenuChange }) {
+export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchConvos, data, pageData, onSave, addButtons, menuItems, menuIndex, onMenuChange }: MessengerProps) {
     const { t } = useTranslation();
     const insets = useSafeAreaInsets();
     const footerHeight = useFooterHeight();
+    const headerHeight = useHeaderHeight();
     const { setBottomSheetData } = useBottomSheetData();
     const [convoId, setConvoId] = useState(defaultConvoId);
+    const selectedMenuRef = useRef(selectedMenu);
+    useEffect(() => {
+        selectedMenuRef.current = selectedMenu;
+    }, [selectedMenu]);
     const convoIdRef = useRef(convoId);
     convoIdRef.current = convoId;
-    const [jots, setJots] = useState(false);
-    const [listError, setListError] = useState(false);
+    const [jots, setJots] = useState<JotsState | false>(false);
+    const [listError, setListError] = useState<unknown>(false);
     const isDesktop = useIsDesktop();
     // Side-by-side panels exist only on web. Native (incl. iPad) is always
     // stack navigation: list → conversation. Width-based "desktop" would leave
@@ -72,19 +110,22 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
     const [panelsVisible, setPanelsVisible] = useState({ convos: true, jots: isSmallScreen ? false : true });
     const panelsVisibleRef = useRef(panelsVisible);
     panelsVisibleRef.current = panelsVisible;
-    const [commentForm, setCommentForm] = useState(false);
-    const [jotUpdated, setJotUpdated] = useState(false);
+    const [commentForm, setCommentForm] = useState<FormData | false>(false);
+    const [jotUpdated, setJotUpdated] = useState<LoosePayload | false>(false);
     const [formHeight, setFormHeight] = useState(0);
     const [splitHeight, setSplitHeight] = useState(0);
-    const [showMsg, setShowMsg] = useState(false);
-    const refListJots = useRef();
+    const [showMsg, setShowMsg] = useState<string | false>(false);
+    const refListJots = useRef<any>(null);
     const isFetchingJots = useRef(false);
     const hasMoreJots = useRef(true);
-    const jotsPaginationRef = useRef(null);
-    const [convosData, setConvosData] = useState(convos?.data || []);
+    const jotsPaginationRef = useRef<LoosePayload | null>(null);
+    // Saved messages removed from the open list since the last page: UNA pages by offset.
+    const removedJotsRef = useRef(0);
+    const [convosData, setConvosData] = useState<Convo[]>((convos && convos.data) || []);
     const selectedConvoIndex = convosData && convoId ? convosData.findIndex(item => item.id === convoId) : -1;
-    const selectedConvo = convosData ? convosData[selectedConvoIndex] : false;
+    const selectedConvo: Convo | undefined = selectedConvoIndex >= 0 ? convosData[selectedConvoIndex] : undefined;
     const { currentUser } = useCurrentUser();
+    const currentUserId = currentUser ? currentUser.id : undefined;
     // NativeTabs screens run under the tab bar (iOS floats it, Android draws
     // it over the page), so lists pad and the composer rests this far up.
     // Window insets, not `insets`: NativeTabs can report the keyboard height
@@ -99,7 +140,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
     const stickyOpened = isWeb
         ? 0
         : tabBarOverlayInset || TAB_BAR_HEIGHT + (Platform.OS === 'android' ? insets.bottom : 0);
-    const [replyItem, setReplyItem] = useState(false);
+    const [replyItem, setReplyItem] = useState<Jot | false>(false);
     // iOS: one blur from the screen bottom up through the composer, instead of
     // the tab screen's bar blur plus a separate one behind the composer.
     const footerBlur = edgeBlurConfig('footer');
@@ -114,7 +155,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
         return () => setPageBottomBlur(false);
     }, [ownsBottomBlur, setPageBottomBlur]);
 
-    const { data: dynamicData } = useFetchForm('/api.php?r=bx_messenger/get_send_form/Services&params=' + JSON.stringify({ id: selectedConvo?.id, convo_id: selectedConvo?.id, reply_id: replyItem ? replyItem?.id : 0 }), commentForm);
+    const { data: dynamicData } = useFetchForm('/api.php?r=bx_messenger/get_send_form/Services&params=' + JSON.stringify({ id: selectedConvo?.id, convo_id: selectedConvo?.id, reply_id: replyItem ? replyItem?.id : 0 }), commentForm || null);
 
     useEffect(() => {
         data.form.data.inputs.message.value = '';
@@ -134,43 +175,76 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
 
     }, [dynamicData]);
 
-    const handleSearch = useCallback((term) => {
+    // Text of the list search; the list isn't refreshed by new messages while it is set.
+    const listSearchRef = useRef('');
+    const handleSearch = useCallback((term: string) => {
+        listSearchRef.current = term;
         fetchConvos(term);
     }, [fetchConvos]);
 
+    // UNA sends 'profile_<id>' to every participant on a new message (broadcastNewJot).
+    // A message in another talk refreshes the list: unread counts, order and previews come
+    // from UNA. Bursts are merged; own messages and the open talk (fed by its own channel) are skipped.
     useEffect(() => {
-        setConvosData(convos?.data || []);
-    }, [convos?.data]);
+        if (!currentUserId) return;
 
-    const fetchItems = useCallback(async (targetConvoId, isAddJots) => {
+        let timer: ReturnType<typeof setTimeout> | undefined;
+        const unsubscribe = subscribe('bx_messenger', 'profile_' + currentUserId, (event) => {
+            if (event?.user_id == currentUserId || listSearchRef.current) return;
+            if (event?.id && event.id == convoIdRef.current && panelsVisibleRef.current.jots) return;
+
+            clearTimeout(timer);
+            timer = setTimeout(() => fetchConvos(), 400);
+        });
+
+        return () => {
+            clearTimeout(timer);
+            unsubscribe();
+        };
+    }, [currentUserId, fetchConvos]);
+
+    useEffect(() => {
+        setConvosData((convos && convos.data) || []);
+    }, [convos]);
+
+    const fetchItems = useCallback(async (targetConvoId: string, isAddJots: boolean) => {
         if (isAddJots && (isFetchingJots.current || !hasMoreJots.current)) return;
 
         const params = jotsPaginationRef.current;
         let start = 0;
 
         if (params?.limit && isAddJots)
-            start = (params.start ?? 0) + params.limit;
+            start = Math.max(0, (params.start ?? 0) + params.limit - removedJotsRef.current);
 
         isFetchingJots.current = true;
         try {
-            let request_url = '/api.php?r=bx_messenger/get_convo_messages/Services&params=' + JSON.stringify({ lot: targetConvoId, jot: 0, start: start });
+            // Saved: only the messages the viewer saved in this talk, as on the site.
+            const area = selectedMenuRef.current === 'saved' ? { area_type: 'saved' } : {};
+            let request_url = '/api.php?r=bx_messenger/get_convo_messages/Services&params=' + JSON.stringify({ lot: targetConvoId, jot: 0, start: start, ...area });
             const sResponse = await fetcher(request_url);
 
-            const newJots = sResponse.data?.jots ?? [];
+            const newJots: Jot[] = sResponse.data?.jots ?? [];
             if (isAddJots && newJots.length === 0) {
                 hasMoreJots.current = false;
                 return;
             }
 
             jotsPaginationRef.current = sResponse.data.params;
+            removedJotsRef.current = 0;
 
             setJots(prevJots => ({
-                ...prevJots,
+                ...(prevJots || {}),
                 data: {
                     params: sResponse.data.params,
-                    jots: prevJots && isAddJots ? [...newJots, ...prevJots?.data?.jots] : newJots
+                    jots: prevJots && isAddJots ? [...newJots, ...(prevJots.data?.jots || [])] : newJots
+                },
+                index: prevJots && isAddJots ? prevJots.index : 0,
+                // Unread on opening the talk (UNA counts them before marking them read):
+                // the 'new messages' line stays where it was while the talk is open.
+                unread: prevJots && isAddJots ? prevJots.unread : {
+                    count: parseInt(sResponse.data?.unread_jots) || 0,
+                    firstId: parseInt(sResponse.data?.last_unread_jot) || 0,
                 },
-                index: prevJots && isAddJots ? prevJots.index : 0
             }));
         } finally {
             isFetchingJots.current = false;
@@ -185,7 +259,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
     // Native (NativeTabs): a conversation is a page pushed onto the tab's
     // stack, so the edge swipe / system back return to the list; its back
     // arrow pops it.
-    const router = useRouter();
+    const router = useRouter() as unknown as MessengerRouter;
     const currentTabPath = useCurrentTabPath();
     const isPushedScreen = useIsPushedScreen();
     const stackedChat = !isWeb && isNativeTabsEnabled();
@@ -196,7 +270,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
         return path === messengerUrl || path.startsWith(messengerUrl + '/');
     }, [messengerUrl]);
 
-    const syncConvoUrl = useCallback((id, { push = false } = {}) => {
+    const syncConvoUrl = useCallback((id: string | undefined, { push = false }: { push?: boolean } = {}) => {
         if (!isWeb || !id || !isOnMessengerPath()) return;
         const target = `${messengerUrl}/${selectedMenu}/${id}/`;
         if (window.location.pathname === target) return;
@@ -258,27 +332,26 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
     // Skip lists fetched for another menu (stale during an inbox/direct switch).
     useEffect(() => {
         if (isSmallScreen) return;
-        if (convos?.menu && convos.menu !== selectedMenu) return;
-        if (convoId == '' && convosData?.length > 0) {
-            const first = convosData[0];
+        if (convos && convos.menu && convos.menu !== selectedMenu) return;
+        const first = convosData[0];
+        if (convoId == '' && first) {
             setConvoId(first.id);
             syncConvoUrl(first.id);
         }
-    }, [convosData, convoId, isSmallScreen, syncConvoUrl, convos?.menu, selectedMenu]);
+    }, [convosData, convoId, isSmallScreen, syncConvoUrl, convos, selectedMenu]);
 
     useEffect(() => {
         if (!selectedConvo) return;
 
         const sub1 = subscribe('bx_messenger', 'convo_' + selectedConvo.id, onNewMessage);
-        const sub2 = subscribe('bx_messenger', 'profile_' + currentUser.id, onCheckConvos);
         hasMoreJots.current = true;
         jotsPaginationRef.current = null;
+        removedJotsRef.current = 0;
         setJots(false);
         fetchItems(selectedConvo.id, false);
 
         return () => {
             sub1();
-            sub2();
         };
     }, [convoId]);
 
@@ -286,26 +359,19 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
         setListError(null);
     }, [convoId]);
 
-    const onNewMessage = (data) => {
-        if (data.id == convoId) {
+    const onNewMessage = (event: LoosePayload) => {
+        if (event.id == convoId) {
             // Someone else's message in the open conversation: one subtle ready per update.
-            const incoming = data.action == 'added' && Array.isArray(data.data?.jots) ? data.data.jots : [];
+            const incoming: Jot[] = event.action == 'added' && Array.isArray(event.data?.jots) ? event.data.jots : [];
             const fromOthers = incoming.some((jot) => {
                 const author = jot?.author_data?.id ?? jot?.author;
-                return author != null && author != currentUser?.id;
+                return author != null && author != currentUserId;
             });
             if (fromOthers) playCue('ready', { emphasis: 'subtle' });
-            setJotUpdated(data);
+            setJotUpdated(event);
         }
     }
 
-    const onCheckConvos = (data) => {
-        /*if (convoId != data.id) {
-            fetchConvos();
-        }*/
-        //MAY BE NEED TO RETURN
-    }
-
     const scrolTo = () => {
         if (refListJots && refListJots?.current) {
             setTimeout(() => {
@@ -324,18 +390,19 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
     useEffect(() => {
         if (!jotUpdated) return;
 
-        const incoming = Array.isArray(jotUpdated.data?.jots) ? jotUpdated.data.jots : [];
+        const incoming: Jot[] = Array.isArray(jotUpdated.data?.jots) ? jotUpdated.data.jots : [];
 
-        if (jotUpdated.action == 'added') {
+        // Saved section: the list holds the viewer's saved messages only, new ones aren't saved yet.
+        if (jotUpdated.action == 'added' && selectedMenuRef.current !== 'saved') {
             if (incoming.length) {
                 const latestJot = incoming[incoming.length - 1];
                 if (latestJot) {
                     setConvosData(prevConvos => {
                         const nextConvos = [...(prevConvos || [])];
                         const targetIndex = nextConvos.findIndex(convo => convo.id == convoId);
-                        if (targetIndex === -1) return prevConvos;
-
                         const targetConvo = nextConvos[targetIndex];
+                        if (targetIndex === -1 || !targetConvo) return prevConvos;
+
                         nextConvos[targetIndex] = {
                             ...targetConvo,
                             message: latestJot.message ?? targetConvo.message,
@@ -345,7 +412,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
                     });
                 }
                 setJots(prevJots => {
-                    const prevList = Array.isArray(prevJots?.data?.jots) ? prevJots.data.jots : [];
+                    const prevList: Jot[] = prevJots ? prevJots.data?.jots || [] : [];
                     const seen = new Set(prevList.map(item => item?.id));
                     const merged = [...prevList];
                     for (const jot of incoming) {
@@ -354,28 +421,29 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
                         merged.push(jot);
                     }
                     return {
-                        ...(prevJots && typeof prevJots === 'object' ? prevJots : {}),
+                        ...(prevJots || {}),
                         data: {
-                            ...(prevJots?.data || {}),
+                            ...(prevJots ? prevJots.data : {}),
                             jots: merged,
                         },
                     };
                 });
                 scrolTo();
             }
-            if (jotUpdated.data?.msg) {
-                setListError(jotUpdated.data.msg);
-            }
         }
-        if (jotUpdated.action == 'edited' && incoming[0]) {
-            const edited = incoming[0];
+        if (jotUpdated.action == 'added' && jotUpdated.data?.msg) {
+            setListError(jotUpdated.data.msg);
+        }
+        const edited = incoming[0];
+        if (jotUpdated.action == 'edited' && edited) {
             setJots(prevJots => {
-                const prevList = Array.isArray(prevJots?.data?.jots) ? prevJots.data.jots : [];
+                const prevList: Jot[] = prevJots ? prevJots.data?.jots || [] : [];
                 return {
-                    ...(prevJots && typeof prevJots === 'object' ? prevJots : {}),
+                    ...(prevJots || {}),
                     data: {
-                        ...(prevJots?.data || {}),
-                        jots: prevList.map(item => item.id === edited.id ? edited : item),
+                        ...(prevJots ? prevJots.data : {}),
+                        // 'saved' in the broadcast is the editor's, keep the viewer's own.
+                        jots: prevList.map(item => item.id === edited.id ? { ...edited, saved: item.saved } : item),
                     },
                 };
             });
@@ -383,11 +451,11 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
         if (jotUpdated.action == 'deleted') {
             const idToRemove = jotUpdated.data;
             setJots(prevJots => {
-                const prevList = Array.isArray(prevJots?.data?.jots) ? prevJots.data.jots : [];
+                const prevList: Jot[] = prevJots ? prevJots.data?.jots || [] : [];
                 return {
-                    ...(prevJots && typeof prevJots === 'object' ? prevJots : {}),
+                    ...(prevJots || {}),
                     data: {
-                        ...(prevJots?.data || {}),
+                        ...(prevJots ? prevJots.data : {}),
                         jots: prevList.filter(item => item.id !== idToRemove),
                     },
                     index: 0,
@@ -396,7 +464,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
         }
     }, [jotUpdated]);
 
-    const changeConvo = useCallback((convo) => {
+    const changeConvo = useCallback((convo: Convo) => {
         if (stackedChat && isSmallScreenRef.current) {
             // No trailing slash: the API redirects `…/<id>/` to `…/<id>`.
             router.push(nativeTabPageHref(`${messengerUrl}/${selectedMenu}/${convo.id}`, currentTabPath));
@@ -429,7 +497,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
 
     // Bottom-tab reselect: leave open chat and show conversation list.
     useEffect(() => {
-        const subscription = emitter.addListener(EVENTS.conductor, (payload) => {
+        const subscription = emitter.addListener(EVENTS.conductor, (payload: LoosePayload) => {
             if (!isFocusedRef.current) return;
             if (payload?.action !== 'reset_to_first') return;
             if (!(isSmallScreen && panelsVisibleRef.current.jots && !panelsVisibleRef.current.convos)) return;
@@ -438,79 +506,199 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
         return () => subscription.remove();
     }, [isSmallScreen, showConvo]);
 
+    // After delete / leave: the list is refetched, the first talk of the current one is opened.
+    const openFirstConvo = useCallback(() => {
+        fetchConvos();
+        const first = convos && convos.data ? convos.data[0] : undefined;
+        setConvoId(first ? first.id : '');
+        syncConvoUrl(first?.id);
+    }, [convos, fetchConvos, syncConvoUrl]);
+
     const deleteConvo = useCallback(async () => {
-        let request_url = '/api.php?r=bx_messenger/delete_convo/Services&params=' + JSON.stringify({ lot: selectedConvo.id2 });
+        let request_url = '/api.php?r=bx_messenger/delete_convo/Services&params=' + JSON.stringify({ lot: selectedConvo?.id2 });
         const sResponse = await fetcher(request_url);
         if (sResponse.data?.message) {
             setShowMsg(sResponse.data?.message)
         }
         else {
-            fetchConvos();
-            setConvoId(convos.data[0].id);
-            syncConvoUrl(convos.data[0].id);
+            openFirstConvo();
         }
-    }, [selectedConvo?.id2, convos.data, syncConvoUrl]);
+    }, [selectedConvo?.id2, openFirstConvo]);
 
     const leaveConvo = useCallback(async () => {
-        let request_url = '/api.php?r=bx_messenger/leave_convo/Services&params=' + JSON.stringify({ lot: selectedConvo.id2 });
+        let request_url = '/api.php?r=bx_messenger/leave_convo/Services&params=' + JSON.stringify({ lot: selectedConvo?.id2 });
         const sResponse = await fetcher(request_url);
         if (sResponse.data?.message) {
             setShowMsg(sResponse.data?.message)
         }
         else {
-            fetchConvos();
-            setConvoId(convos.data[0].id);
-            syncConvoUrl(convos.data[0].id);
+            openFirstConvo();
         }
-    }, [selectedConvo?.id2, convos.data, syncConvoUrl]);
+    }, [selectedConvo?.id2, openFirstConvo]);
 
     const getConvo = useCallback(async () => {
-        let request_url = '/api.php?r=bx_messenger/get_convo/Services&params=' + JSON.stringify({ lot: selectedConvo.id2 });
+        let request_url = '/api.php?r=bx_messenger/get_convo/Services&params=' + JSON.stringify({ lot: selectedConvo?.id2 });
         const sResponse = await fetcher(request_url);
         if (sResponse.data?.message) {
             setShowMsg(sResponse.data?.message)
         }
         else {
-            const content = (<View className='items-center justify-center'>
-                <Row className='mb-4 gap-x-4'>
-                    <Profile displaySize="base" displayType="unit_wo_info" {...sResponse.data.lot.author_data} />
-                    <View>
-                        <Text className="text-popover-foreground ">{t('Participants:')} {sResponse.data.lot.parts}</Text>
-                        <Text className="text-popover-foreground ">{t('Messages:')} {sResponse.data.lot.messages}</Text>
-                        <Text className="text-popover-foreground ">{t('Files:')} {sResponse.data.lot.files}</Text>
-                    </View>
-                </Row>
-            </View>)
-            setBottomSheetData({ title: 'Conversation info', content: content, showClose: true, snapPoints: ['25%', '50%'] });
+            const lot = sResponse.data.lot;
+            // Labels without a trailing colon: i18next reads "Participants:" as a namespace and renders nothing.
+            const stats: [string, ReactNode][] = [
+                [t('Participants'), lot.parts],
+                [t('Messages'), lot.messages],
+                [t('Files'), lot.files],
+            ];
+            // Author (avatar + name) on the left, stats on the right.
+            const content = (<Row className='w-full items-center gap-x-8 pb-4'>
+                <View className='items-center gap-y-3 shrink-0 max-w-48'>
+                    <Profile displaySize="2xl" displayType="unit_wo_info" {...lot.author_data} />
+                    {lot.author_data?.display_name ? (
+                        <Text className="text-popover-foreground text-xl font-bold text-center" numberOfLines={2}>{lot.author_data.display_name}</Text>
+                    ) : null}
+                </View>
+                <View className='flex-1 min-w-0 gap-y-3'>
+                    {stats.map(([label, value]) => (
+                        <Text key={label} className="text-muted-foreground text-base">{label}: {value ?? 0}</Text>
+                    ))}
+                </View>
+            </Row>)
+            setBottomSheetData({ title: t('Conversation info'), content: content, showClose: true, snapPoints: ['50%', '50%'] });
         }
-    }, [selectedConvo?.id2, convos.data]);
+    }, [selectedConvo?.id2]);
 
-    const onSaveHandler = (data) => {
+    const onSaveHandler = (saved: LoosePayload) => {
         setBottomSheetData(false);
-        onSave(data);
+        onSave(saved);
     }
 
     const editConvo = useCallback(async () => {
-        let request_url = '/api.php?r=bx_messenger/get_parts_list/Services&params=' + JSON.stringify({ lot: selectedConvo.id2 });
+        let request_url = '/api.php?r=bx_messenger/get_parts_list/Services&params=' + JSON.stringify({ lot: selectedConvo?.id2 });
         const sResponse = await fetcher(request_url);
-        setBottomSheetData({ title: t('messenger_add_users'), content: <CreateConvo onSave={onSaveHandler} initedData={sResponse.data} convoId={selectedConvo.id2} />, showClose: true, snapPoints: ['85%', '85%'] });
-    }, [selectedConvo?.id2, convos.data]);
+        setBottomSheetData({ title: t('messenger_add_users'), content: <CreateConvoForm onSave={onSaveHandler} initedData={sResponse.data} convoId={selectedConvo?.id2} />, showClose: true, snapPoints: ['85%', '85%'] });
+    }, [selectedConvo?.id2]);
 
-    const handleConvoAction = useCallback((item) => {
+    const clearConvo = useCallback(async () => {
+        const sResponse = await fetcher('/api.php?r=bx_messenger/clear_convo/Services&params=' + JSON.stringify({ lot: selectedConvo?.id2 }));
+        if (sResponse.data?.code != 0) {
+            setShowMsg(sResponse.data?.message || t('Error'));
+            return;
+        }
+        hasMoreJots.current = false;
+        jotsPaginationRef.current = null;
+        setJots(prevJots => ({ ...(prevJots || {}), data: { ...(prevJots ? prevJots.data : {}), jots: [] }, index: 0 }));
+        setConvosData(prevConvos => (prevConvos || []).map(convo => convo.id === selectedConvo?.id ? { ...convo, message: '' } : convo));
+    }, [selectedConvo]);
+
+    const openSettings = useCallback(async () => {
+        const lotId = selectedConvo?.id2;
+        const sResponse = await fetcher('/api.php?r=bx_messenger/get_convo_settings/Services&params=' + JSON.stringify({ lot: lotId }));
+        if (sResponse.data?.message || !Array.isArray(sResponse.data?.options)) {
+            setShowMsg(sResponse.data?.message || t('Error'));
+            return;
+        }
+        setBottomSheetData({
+            title: t('Settings'),
+            content: (
+                <ConvoSettings
+                    lotId={lotId ?? ''}
+                    settings={sResponse.data}
+                    onSaved={() => setBottomSheetData(false)}
+                    onError={(message: string) => {
+                        setBottomSheetData(false);
+                        setShowMsg(message);
+                    }}
+                />
+            ),
+            showClose: true,
+            snapPoints: ['50%', '50%'],
+        });
+    }, [selectedConvo?.id2]);
+
+    // Header search in the open conversation's messages; it closes when another conversation opens.
+    const [convoSearch, setConvoSearch] = useState<{ convoId: string | null; term: string }>({ convoId: null, term: '' });
+    const searchOpen = !!selectedConvo && convoSearch.convoId === convoId;
+    const toggleSearch = useCallback(() => {
+        setConvoSearch(prev => (prev.convoId === convoIdRef.current ? { convoId: null, term: '' } : { convoId: convoIdRef.current, term: '' }));
+    }, []);
+    const handleSearchTerm = useCallback((term: string) => {
+        setConvoSearch(prev => (prev.term === term ? prev : { ...prev, term }));
+    }, []);
+
+    const openFiles = useCallback(() => {
+        setBottomSheetData({
+            title: t('Files'),
+            content: <ConvoFiles lotId={selectedConvo?.id2 ?? ''} />,
+            showClose: true,
+            snapPoints: ['85%', '85%'],
+        });
+    }, [selectedConvo?.id2]);
+
+    // Delete / clear can be a single tap on the header button, so they ask first.
+    const [confirmAction, setConfirmAction] = useState<'delete' | 'clear' | false>(false);
+
+    // Mute / star: toggles for the viewer, UNA returns the new state.
+    const toggleConvo = useCallback(async (action: 'mute' | 'star') => {
+        const sResponse = await fetcher('/api.php?r=bx_messenger/' + action + '_convo/Services&params=' + JSON.stringify({ lot: selectedConvo?.id2 }));
+        if (sResponse.data?.code != 0) {
+            playCue('error');
+            setShowMsg(sResponse.data?.message || t('Error'));
+            return;
+        }
+        playCue('toggle', { emphasis: 'subtle' });
+        if (action === 'star') {
+            // Star badge in the list follows the new state right away.
+            const lotId = selectedConvo?.id2;
+            setConvosData(prevConvos => (prevConvos || []).map(convo => convo.id2 == lotId ? { ...convo, starred: sResponse.data.value } : convo));
+            // Favorites list shows starred talks only: refresh it after a star change.
+            if (selectedMenu === 'favorites') fetchConvos();
+        }
+    }, [selectedConvo?.id2, selectedMenu, fetchConvos]);
+
+    const handleConvoAction = useCallback((item: ConvoAction) => {
+        if (item.id === 'mute' || item.id === 'star') toggleConvo(item.id);
         if (item.id === 'edit') editConvo();
         if (item.id === 'leave') leaveConvo();
         if (item.id === 'info') getConvo();
-        if (item.id === 'delete') deleteConvo();
-    }, [editConvo, leaveConvo, getConvo, deleteConvo]);
+        if (item.id === 'settings') openSettings();
+        if (item.id === 'files') openFiles();
+        if (item.id === 'delete' || item.id === 'clear') setConfirmAction(item.id);
+    }, [editConvo, leaveConvo, getConvo, openSettings, openFiles, toggleConvo]);
+
+    const confirmDialog = (
+        <ConfirmDialog
+            onVisible={!!confirmAction}
+            title={t('Are you sure?')}
+            handleCancel={() => setConfirmAction(false)}
+            handleOk={() => {
+                const action = confirmAction;
+                setConfirmAction(false);
+                if (action === 'delete') deleteConvo();
+                if (action === 'clear') clearConvo();
+            }}
+        />
+    );
 
-    const onFormSubmit = useCallback((formData) => {
+    const onFormSubmit = useCallback((formData: FormData) => {
         setCommentForm(formData);
     }, []);
 
-    const handleReply = useCallback((item) => {
+    const handleReply = useCallback((item: Jot) => {
         setReplyItem(item)
     }, [])
 
+    // Saved section: a message removed from saved leaves the list; an emptied talk leaves the talks list.
+    const handleSavedChange = useCallback((item: Jot, saved: boolean) => {
+        if (saved || selectedMenuRef.current !== 'saved') return;
+
+        const rest = (jots ? jots.data.jots : []).filter(jot => jot.id !== item.id);
+        // UNA pages saved messages by offset: one less of them, one less to skip on the next page.
+        removedJotsRef.current += 1;
+        setJots(prevJots => (prevJots ? { ...prevJots, data: { ...prevJots.data, jots: prevJots.data.jots.filter(jot => jot.id !== item.id) } } : prevJots));
+        if (!rest.length) fetchConvos();
+    }, [jots, fetchConvos]);
+
     const handleCancelReply = useCallback(() => {
         setReplyItem(false)
     }, [])
@@ -520,11 +708,11 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
             fetchItems(selectedConvo.id, true);
     }, [selectedConvo?.id])
 
-    const handleFormLayout = useCallback((event) => {
+    const handleFormLayout = useCallback((event: LoosePayload) => {
         setFormHeight(event.nativeEvent.layout.height)
     }, []);
 
-    const handleSplitLayout = useCallback((event) => {
+    const handleSplitLayout = useCallback((event: LoosePayload) => {
         const { height } = event.nativeEvent.layout;
         if (height > 0)
             setSplitHeight(prev => (prev === height ? prev : height));
@@ -538,7 +726,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
     const injectedHeader = useMemo(() => {
         if (!isSmallScreen) return null;
         if (panelsVisible.jots && selectedConvo) {
-            return <MobileChatHeader title={selectedConvo.title} onBack={showConvo} onAction={handleConvoAction} />;
+            return <MobileChatHeader title={selectedConvo.title} onBack={showConvo} menu={selectedConvo.menu} menuKey={selectedConvo.id} onAction={handleConvoAction} searchOpen={searchOpen} onSearchToggle={toggleSearch} onSearch={handleSearchTerm} />;
         }
         return (
             <MobileListHeader
@@ -550,22 +738,24 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
                 onMenuChange={onMenuChange}
             />
         );
-    }, [isSmallScreen, panelsVisible.jots, selectedConvo?.id, selectedConvo?.title, showConvo, handleConvoAction, pageData, handleSearch, addButtons, menuItems, menuIndex, onMenuChange]);
+    }, [isSmallScreen, panelsVisible.jots, selectedConvo?.id, selectedConvo?.title, showConvo, selectedConvo?.menu, handleConvoAction, searchOpen, toggleSearch, handleSearchTerm, pageData, handleSearch, addButtons, menuItems, menuIndex, onMenuChange]);
 
     // ---------------------------------------------------------------------
     // Render
     // ---------------------------------------------------------------------
-    const jotsLoaded = !!(panelsVisible.jots && selectedConvo && jots?.data?.jots);
+    const jotsLoaded = !!(panelsVisible.jots && selectedConvo && jots && jots.data?.jots);
+    const jotsList: Jot[] = jots ? jots.data.jots : [];
+    const unread = jots ? jots.unread : undefined;
     const errorOffset = listError ? 60 : 0;
     const convosListProps = {
         title: t('Messages'),
         emptyText: t('No conversations found'),
-        emptyAction: ({ resetSearch }) => (
-            addButtons?.length > 0 ? <CreateConvoButton variant="secondary" onSave={onSave} onShow={resetSearch} /> : null
+        emptyAction: ({ resetSearch }: { resetSearch: () => void }) => (
+            Array.isArray(addButtons) && addButtons.length > 0 ? <CreateConvoButton variant="secondary" onSave={onSave} onShow={resetSearch} /> : null
         ),
         // Small screens show the list or the chat, never both, so a "current" row
         // would only linger after Back. Desktop keeps it highlighted next to the open chat.
-        renderItem: ({ item, index }) => (
+        renderItem: ({ item, index }: { item: Convo; index: number }) => (
             <ItemConvo selectedIndex={isSmallScreen ? -1 : selectedConvoIndex} item={item} index={index} changeConvo={changeConvo} />
         ),
     };
@@ -597,10 +787,21 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
                 </View>}
                 {panelsVisible.jots && (
                     <View className="relative w-full">
-                    <Msg onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false) }} />
-                    {jotsLoaded && (
+                    <MsgDialog onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false) }} />
+                    {confirmDialog}
+                    {searchOpen ? (
+                        <ConvoSearchResults
+                            lotId={selectedConvo?.id2 ?? ''}
+                            term={convoSearch.term}
+                            headerInset={isWeb ? 0 : headerHeight}
+                            footerInset={composerInset + bottomBarInset}
+                        />
+                    ) : null}
+                    {jotsLoaded && !searchOpen && (
                         <JotsPanel
-                            data={jots.data.jots}
+                            data={jotsList}
+                            unread={unread}
+                            onSavedChange={handleSavedChange}
                             refListJots={refListJots}
                             startReached={handleStartReached}
                             handleReply={handleReply}
@@ -623,9 +824,10 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
                             // Web: from the window bottom, under the tab bar (footer z-30),
                             // so the wash runs from the bar up through the composer.
                             // iOS (ownsBottomBlur) the same, padded up to its resting spot.
-                            style={isWeb
+                            // 'fixed' is a web-only position, outside React Native's types.
+                            style={(isWeb
                                 ? { position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 20 }
-                                : { position: 'absolute', left: 0, right: 0, bottom: ownsBottomBlur ? 0 : tabBarOverlayInset, zIndex: 10 }}
+                                : { position: 'absolute', left: 0, right: 0, bottom: ownsBottomBlur ? 0 : tabBarOverlayInset, zIndex: 10 }) as unknown as ViewStyle}
                             offset={{ closed: 0, opened: stickyOpened }}
                         >
                             <EdgeBlurView
@@ -677,12 +879,23 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
             }
             center={
                     <View className='relative h-full min-h-0 overflow-hidden border-border/60 lg:border-l '>
-                        <Msg onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false) }} />
+                        <MsgDialog onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false) }} />
+                        {confirmDialog}
                         {selectedConvo ? (
                             <>
-                                {jotsLoaded ? (
+                                {searchOpen ? (
+                                    <ConvoSearchResults
+                                        lotId={selectedConvo.id2}
+                                        term={convoSearch.term}
+                                        headerInset={PANEL_HEADER_HEIGHT}
+                                        footerInset={formHeight + errorOffset}
+                                    />
+                                ) : null}
+                                {jotsLoaded && !searchOpen ? (
                                     <JotsPanel
-                                        data={jots.data.jots}
+                                        data={jotsList}
+                                        unread={unread}
+                                        onSavedChange={handleSavedChange}
                                         listHeight={desktopJotsHeight}
                                         refListJots={refListJots}
                                         startReached={handleStartReached}
@@ -694,7 +907,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
                                 ) : null}
                                 <EdgeBlurView edge="top" config={edgeBlurConfig('footer')} className={CHAT_OVERLAY_HEADER} washClassName={CHAT_OVERLAY_HEADER_FADE} pointerEvents="box-none">
                                     <View pointerEvents="auto">
-                                        <PanelHeader title={selectedConvo.title} actions={<ConvoActionsMenu onAction={handleConvoAction} style="glass" />} />
+                                        <PanelHeader title={selectedConvo.title || ''} search={searchOpen ? <ConvoSearchField onSearch={handleSearchTerm} /> : null} actions={<Row className="items-center gap-3"><ConvoSearchButton open={searchOpen} onPress={toggleSearch} style="glass" /><ConvoActionsMenu key={selectedConvo.id} menu={selectedConvo.menu} onAction={handleConvoAction} style="glass" /></Row>} />
                                     </View>
                                 </EdgeBlurView>
                                 {listError ? (

+ 104 - 0
neo/packages/app/components/elements/messenger/parts/convo-files.tsx

@@ -0,0 +1,104 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Linking } from 'react-native';
+import { useTranslation } from 'react-i18next';
+import { Text } from 'app/design/typography';
+import { View, Row, Pressable } from 'app/design/view';
+import { NeoButton } from 'app/design/controls';
+import Image from 'app/ui/atoms/image';
+import { Icon } from 'app/ui/atoms/icon';
+import { fetcher } from 'app/lib/fetcher';
+import { pickUnaDisplaySrc } from 'app/lib/image-helpers';
+
+type ConvoFile = {
+    id: number;
+    name: string;
+    url: string;
+    image?: unknown;
+};
+
+type ConvoFilesProps = {
+    lotId: string | number;
+};
+
+const openFile = (url: string) => {
+    if (url) Linking.openURL(url).catch(() => {});
+};
+
+/** Files sent in a conversation (UNA `bx_messenger/get_convo_files`): image grid, then other files. */
+export default function ConvoFiles({ lotId }: ConvoFilesProps) {
+    const { t } = useTranslation();
+    const [files, setFiles] = useState<ConvoFile[]>([]);
+    const [total, setTotal] = useState(0);
+    const [loading, setLoading] = useState(true);
+    const [error, setError] = useState('');
+
+    // No state is set before the request: the first page loads from an effect.
+    const load = useCallback(async (start: number) => {
+        try {
+            const params = JSON.stringify({ lot: lotId, start });
+            const response = await fetcher('/api.php?r=bx_messenger/get_convo_files/Services&params=' + params);
+            const data = response?.data;
+            if (data?.code != 0 || !Array.isArray(data?.files)) {
+                setError(data?.message || t('Error'));
+                return;
+            }
+            setFiles((prev) => (start ? [...prev, ...data.files] : data.files));
+            setTotal(data.params?.total ?? 0);
+        } finally {
+            setLoading(false);
+        }
+    }, [lotId, t]);
+
+    useEffect(() => {
+        load(0);
+    }, [load]);
+
+    if (error)
+        return <Text className="text-muted-foreground text-base text-center py-4">{error}</Text>;
+
+    if (!loading && !files.length)
+        return <Text className="text-muted-foreground text-base text-center py-4">{t('No files')}</Text>;
+
+    const images = files.filter((file) => pickUnaDisplaySrc(file.image));
+    const others = files.filter((file) => !pickUnaDisplaySrc(file.image));
+
+    return (
+        <View className="w-full gap-y-4 pb-4">
+            {images.length > 0 ? (
+                <Row className="flex-wrap -mx-1">
+                    {images.map((file) => (
+                        <View key={file.id} className="w-1/3 sm:w-1/4 p-1">
+                            <Pressable
+                                className="w-full aspect-square rounded-xl overflow-hidden bg-muted"
+                                accessibilityLabel={file.name}
+                                onPress={() => openFile(file.url)}
+                            >
+                                <Image src={pickUnaDisplaySrc(file.image)} alt={file.name} view="cover" sizes="160px" />
+                            </Pressable>
+                        </View>
+                    ))}
+                </Row>
+            ) : null}
+            {others.length > 0 ? (
+                <View className="gap-y-1">
+                    {others.map((file) => (
+                        <Pressable key={file.id} onPress={() => openFile(file.url)}>
+                            <Row className="items-center gap-x-3 py-2">
+                                <Icon icon="File" />
+                                <Text className="flex-1 min-w-0 text-popover-foreground text-base" numberOfLines={1}>{file.name}</Text>
+                            </Row>
+                        </Pressable>
+                    ))}
+                </View>
+            ) : null}
+            {files.length < total || loading ? (
+                <Row className="justify-center">
+                    <NeoButton style="bordered" controlSize="regular" loading={loading} label={t('Load more')} onPress={() => {
+                        setLoading(true);
+                        load(files.length);
+                    }} />
+                </Row>
+            ) : null}
+        </View>
+    );
+}

+ 116 - 0
neo/packages/app/components/elements/messenger/parts/convo-search.tsx

@@ -0,0 +1,116 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Text } from 'app/design/typography';
+import { View, Row, ScrollView } from 'app/design/view';
+import { NeoButton } from 'app/design/controls';
+import MessageItem from 'app/components/elements/chat/parts/message-item';
+import { ContentMore } from 'app/ui/molecules/content/content-more';
+import { fetcher } from 'app/lib/fetcher';
+
+type FoundJot = {
+    id: number;
+    message: string;
+    added: number;
+    author_data?: { display_name?: string; [key: string]: unknown };
+};
+
+type ConvoSearchResultsProps = {
+    lotId: string | number;
+    /** Debounced text from the search field in the chat header. */
+    term: string;
+    headerInset?: number;
+    footerInset?: number;
+};
+
+/**
+ * Messages of one conversation found by text (UNA
+ * `bx_messenger/search_convo_messages`). Shown in place of the message list
+ * while the header search is open.
+ */
+export default function ConvoSearchResults({ lotId, term, headerInset = 0, footerInset = 0 }: ConvoSearchResultsProps) {
+    const { t } = useTranslation();
+    const [jots, setJots] = useState<FoundJot[]>([]);
+    const [hasMore, setHasMore] = useState(false);
+    const [loading, setLoading] = useState(false);
+    const [error, setError] = useState('');
+    // The query the shown results belong to.
+    const [resultsFor, setResultsFor] = useState('');
+    const requestId = useRef(0);
+    const query = term.trim();
+    // Results and paging belong to one query: while the next one loads, the old page isn't shown or extended.
+    const current = resultsFor === query;
+
+    // No state is set before the request: the first page loads from an effect.
+    const load = useCallback(async (start: number) => {
+        const id = ++requestId.current;
+        try {
+            const params = encodeURIComponent(JSON.stringify({ lot: lotId, term: query, start }));
+            const response = await fetcher('/api.php?r=bx_messenger/search_convo_messages/Services&params=' + params);
+            if (id !== requestId.current) return;
+
+            const data = response?.data;
+            if (data?.code != 0 || !Array.isArray(data?.jots)) {
+                setError(data?.message || t('Error'));
+                return;
+            }
+            setError('');
+            setResultsFor(query);
+            setJots((prev) => (start ? [...prev, ...data.jots] : data.jots));
+            setHasMore(data.jots.length >= (data.params?.per_page ?? 20));
+        } finally {
+            if (id === requestId.current) setLoading(false);
+        }
+    }, [lotId, query, t]);
+
+    useEffect(() => {
+        if (query) load(0);
+    }, [query, load]);
+
+    let content = null;
+    if (!query)
+        content = <Text className="text-muted-foreground text-base text-center">{t('Search messages in this chat')}</Text>;
+    else if (error)
+        content = <Text className="text-muted-foreground text-base text-center">{error}</Text>;
+    else if (current && !jots.length && !hasMore)
+        content = <Text className="text-muted-foreground text-base text-center">{t('Nothing found')}</Text>;
+
+    return (
+        <ScrollView
+            className="h-full w-full"
+            contentContainerClassName="w-full"
+            contentContainerStyle={{ paddingTop: headerInset + 16, paddingBottom: footerInset + 16 }}
+        >
+            {content ? <View className="px-4">{content}</View> : null}
+            {/* Same look as the conversation: MessageItem with the body as in ItemJot. */}
+            {query && current ? jots.map((jot) => (
+                <MessageItem key={jot.id} author={jot.author_data || {}} time={jot.added}>
+                    <View className="pb-2">
+                        <ContentMore
+                            content={jot.message}
+                            embed={undefined}
+                            numberOfLines={3}
+                            numberOfSymbols={360}
+                            openSmall={false}
+                            showLess={true}
+                            customClassName="u-vanilla-html-small"
+                        />
+                    </View>
+                </MessageItem>
+            )) : null}
+            {query && current && hasMore ? (
+                <Row className="justify-center pt-3">
+                    <NeoButton
+                        style="bordered"
+                        controlSize="regular"
+                        loading={loading}
+                        label={t('Load more')}
+                        onPress={() => {
+                            setLoading(true);
+                            load(jots.length);
+                        }}
+                    />
+                </Row>
+            ) : null}
+        </ScrollView>
+    );
+}

+ 67 - 0
neo/packages/app/components/elements/messenger/parts/convo-settings.tsx

@@ -0,0 +1,67 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Text } from 'app/design/typography';
+import { View, Row } from 'app/design/view';
+import { NeoButton } from 'app/design/controls';
+import Switch from 'app/ui/atoms/switcher';
+import { fetcher } from 'app/lib/fetcher';
+import { playCue } from 'app/lib/sound';
+
+export type ConvoSettingsData = {
+    title?: string;
+    options: { key: string; value: string }[];
+    values: string[];
+};
+
+type ConvoSettingsProps = {
+    lotId: string | number;
+    settings: ConvoSettingsData;
+    onSaved: () => void;
+    onError: (message: string) => void;
+};
+
+/** Actions participants may perform in a conversation (UNA `bx_messenger/get_convo_settings`). */
+export default function ConvoSettings({ lotId, settings, onSaved, onError }: ConvoSettingsProps) {
+    const { t } = useTranslation();
+    const [values, setValues] = useState<string[]>(settings.values || []);
+    const [saving, setSaving] = useState(false);
+
+    const toggle = (key: string) => {
+        setValues((prev) => (prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key]));
+    };
+
+    const save = async () => {
+        setSaving(true);
+        try {
+            const params = encodeURIComponent(JSON.stringify({ lot: lotId, options: values }));
+            const response = await fetcher('/api.php?r=bx_messenger/save_convo_settings/Services&params=' + params);
+            if (response?.data?.code == 0) {
+                playCue('success', { emphasis: 'subtle' });
+                onSaved();
+            }
+            else {
+                playCue('error');
+                onError(response?.data?.message || t('Error'));
+            }
+        } finally {
+            setSaving(false);
+        }
+    };
+
+    return (
+        <View className="w-full gap-y-4 pb-4">
+            {settings.title ? <Text className="text-muted-foreground text-base">{settings.title}</Text> : null}
+            <View className="gap-y-3">
+                {settings.options.map((option) => (
+                    <Row key={option.key} className="items-center gap-x-3">
+                        <Switch value={values.includes(option.key)} onValueChange={() => toggle(option.key)} />
+                        <Text className="text-popover-foreground text-base">{option.value}</Text>
+                    </Row>
+                ))}
+            </View>
+            <Row className="justify-end">
+                <NeoButton style="borderedProminent" controlSize="regular" loading={saving} label={t('Save')} onPress={save} />
+            </Row>
+        </View>
+    );
+}

+ 0 - 136
neo/packages/app/components/elements/messenger/parts/headers.js

@@ -1,136 +0,0 @@
-import { cloneElement, useState } from 'react';
-import { Platform } from 'react-native';
-import { Text } from 'app/design/typography';
-import { View, Row } from 'app/design/view';
-import { NeoButton } from 'app/design/controls';
-import { appSetting } from 'app/lib/util';
-import { getBackButtonWeb } from 'app/lib/common-helpers';
-import DropdownMenu from 'app/ui/atoms/dropdown-menu';
-import { components } from 'app/components/registry';
-import { useTranslation } from 'react-i18next';
-import { MenuSwitcher, SearchInput, useDebouncedSearch } from 'app/components/elements/chat/parts/headers';
-
-const isWeb = Platform.OS === 'web';
-
-/** Native: `header.content` uses fixed `h-16`; messenger title/actions can paint taller. */
-export function messengerHeaderContentClass() {
-    const content = appSetting('layout', 'header', 'content');
-    return isWeb ? content : String(content || '').replace(/\bh-16\b/g, 'min-h-16');
-}
-
-const CONVO_MENU_ITEMS = [
-    { id: 'edit', title: 'Edit participants list', icon: 'Users' },
-    { id: 'info', title: 'Info', icon: 'Info' },
-    { id: 'leave', title: 'Leave chat', icon: 'LogOut' },
-    { id: 'delete', title: 'Delete chat', icon: 'Trash' },
-];
-
-/** Settings dropdown with the per-conversation actions (edit / info / leave / delete). */
-export function ConvoActionsMenu({ onAction, style = 'glass' }) {
-    const { t } = useTranslation();
-    return (
-        <DropdownMenu onSelect={onAction} items={CONVO_MENU_ITEMS.map((item) => ({ ...item, title: t(item.title) }))}>
-            <NeoButton
-                style={style}
-                controlSize="regular"
-                borderShape="circle"
-                image="Settings"
-                accessibilityLabel={t('Settings')}
-            />
-        </DropdownMenu>
-    );
-}
-
-/**
- * Small screens, list mode: injected page header (stable "Messages" title +
- * search + create, plus the inbox/direct switcher row). The switcher lives
- * inside this component because PageHeader skips the `subHeader` slot when a
- * custom `header` is injected.
- */
-export function MobileListHeader({ pageData, onSearch, addButtons, menuItems, menuIndex, onMenuChange }) {
-    const { t } = useTranslation();
-    const [showSearch, setShowSearch] = useState(false);
-    const search = useDebouncedSearch(onSearch);
-    const ContextSelector = components['molecule']['context_selector'];
-    const listTitle = t('Messages');
-
-    // Header height comes from PageHeader's own onLayout, which also counts the
-    // edge-to-edge status bar inset. Measuring here would publish only this
-    // content's height and slide the first list row under the inbox/direct row.
-    return (
-        <View className="w-full">
-            <Row className={`${appSetting('layout', 'page_content_width_default')} ${messengerHeaderContentClass()} max-w-full`}>
-                {showSearch ? (
-                    <View className="flex-1 min-w-0 px-4">
-                        <SearchInput value={search.value} onChangeText={search.setValue} />
-                    </View>
-                ) : (
-                    <Row className="flex-1 min-w-0 items-center gap-3 px-4 ">
-                        {appSetting('messenger', 'back_button') ? getBackButtonWeb() : null}
-                        {appSetting('context_selector', 'show_always') ? (
-                            <ContextSelector url={pageData?.url} uri={pageData?.uri} data={pageData?.context} />
-                        ) : (
-                            <Text className="font-bold truncate flex-1 min-w-0 text-card-foreground text-2xl tracking-tight font-main" numberOfLines={1}>
-                                {listTitle}
-                            </Text>
-                        )}
-                    </Row>
-                )}
-                <Row className="my-auto shrink-0 items-center gap-3 pe-4">
-                    <NeoButton
-                        style="glass"
-                        selected={showSearch}
-                        selectedState="pressedToggle"
-                        controlSize="regular"
-                        borderShape="circle"
-                        image="Search"
-                        accessibilityLabel={t('Search')}
-                        onPress={() => {
-                            setShowSearch((prev) => {
-                                if (prev) search.reset();
-                                return !prev;
-                            });
-                        }}
-                    />
-                    {Array.isArray(addButtons)
-                        ? addButtons.map((button, i) => (button ? cloneElement(button, { key: button.key ?? i, style: 'glass' }) : null))
-                        : addButtons}
-                </Row>
-            </Row>
-            {menuItems?.length > 1 ? (
-                // Same box as the conductor tab row (web `conductor.menu_cnt`,
-                // native-ui TabBar), so pills sit at the same height under the title.
-                <Row className="w-full items-center px-4 min-h-13 lg:min-h-14 mb-0.5 lg:mb-0">
-                    <MenuSwitcher items={menuItems} index={menuIndex} onChange={onMenuChange} />
-                </Row>
-            ) : null}
-        </View>
-    );
-}
-
-/** Small screens, chat mode: injected page header (back + title + actions). */
-export function MobileChatHeader({ title, onBack, onAction }) {
-    const { t } = useTranslation();
-    return (
-        <Row className={`${appSetting('layout', 'page_content_width_default')} ${messengerHeaderContentClass()} max-w-full`}>
-            <Row className="flex-1 min-w-0 items-center gap-3 px-4 ">
-                <View className="shrink-0">
-                    <NeoButton
-                        style="glass"
-                        controlSize="regular"
-                        borderShape="circle"
-                        image="ArrowLeft"
-                        accessibilityLabel={t('Back')}
-                        onPress={onBack}
-                    />
-                </View>
-                <Text numberOfLines={1} className="flex-1 min-w-0 font-bold text-card-foreground text-lg sm:text-xl tracking-tight">
-                    {title}
-                </Text>
-            </Row>
-            <View className="shrink-0 items-center justify-center pe-4">
-                <ConvoActionsMenu onAction={onAction} style="glass" />
-            </View>
-        </Row>
-    );
-}

+ 279 - 0
neo/packages/app/components/elements/messenger/parts/headers.tsx

@@ -0,0 +1,279 @@
+import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Platform } from 'react-native';
+import { Text } from 'app/design/typography';
+import { View, Row } from 'app/design/view';
+import { NeoButton } from 'app/design/controls';
+import { appSetting } from 'app/lib/util';
+import { getBackButtonWeb } from 'app/lib/common-helpers';
+import DropdownMenu, { type DropdownMenuItemData } from 'app/ui/atoms/dropdown-menu';
+import { getDataForMenu } from 'app/lib/util/menu';
+import { components } from 'app/components/registry';
+import { useTranslation } from 'react-i18next';
+import { MenuSwitcher, SearchInput, useDebouncedSearch } from 'app/components/elements/chat/parts/headers';
+import type { AddButtons, ApiMenu, ConvoAction, MessengerMenuItem, ShortMenu } from 'app/components/elements/messenger/parts/messenger.types';
+
+const isWeb = Platform.OS === 'web';
+const registry = components as Record<string, Record<string, any> | undefined>;
+
+/** Native: `header.content` uses fixed `h-16`; messenger title/actions can paint taller. */
+export function messengerHeaderContentClass() {
+    const content = appSetting('layout', 'header', 'content');
+    return isWeb ? content : String(content || '').replace(/\bh-16\b/g, 'min-h-16');
+}
+
+// Actions NEO can handle, keyed by item name in UNA's `bx_messenger_lot_info_menu`.
+const CONVO_ACTIONS: Record<string, ConvoAction> = {
+    // From the talk menu (`bx_messenger_lot_menu`), which UNA merges in for the app.
+    star: { id: 'star', title: 'Mark as important', icon: 'Star' },
+    mute: { id: 'mute', title: 'Mute', icon: 'Bell', iconOn: 'BellOff' },
+    add_participants: { id: 'edit', title: 'Edit participants list', icon: 'Users' },
+    info: { id: 'info', title: 'Info', icon: 'Info' },
+    leave: { id: 'leave', title: 'Leave chat', icon: 'LogOut' },
+    delete: { id: 'delete', title: 'Delete chat', icon: 'Trash' },
+    media: { id: 'files', title: 'Files', icon: 'Paperclip' },
+    clear: { id: 'clear', title: 'Clear history', icon: 'Eraser' },
+    settings: { id: 'settings', title: 'Settings', icon: 'Settings' },
+};
+
+/**
+ * Conversation actions from UNA's `bx_messenger_lot_info_menu` (Studio order;
+ * UNA leaves out what the viewer may not run); items NEO can't handle are
+ * dropped. Without the menu (older UNA) the basic actions are shown.
+ */
+export function getConvoActions(menu: ApiMenu | null | undefined): ConvoAction[] {
+    // Older UNA has no services for clear / settings.
+    if (!Array.isArray(menu?.items))
+        return ['add_participants', 'info', 'leave', 'delete'].flatMap((name) => CONVO_ACTIONS[name] ?? []);
+
+    return menu.items.flatMap((item) => {
+        const action = item?.name ? CONVO_ACTIONS[item.name] : undefined;
+        if (!action) return [];
+        // Toggles (mute, star) carry their current state in `value`.
+        const on = Number(item.value) === 1;
+        return [{ ...action, title: item.title || action.title, icon: on && action.iconOn ? action.iconOn : action.icon, value: on }];
+    });
+}
+
+type ConvoActionsMenuProps = {
+    menu?: ShortMenu;
+    onAction: (action: ConvoAction) => void;
+    style?: string;
+};
+
+/**
+ * Gear dropdown with the per-conversation actions. `menu` is the short menu
+ * that comes with the conversation (`object` + `params`); its items depend on
+ * the viewer's rights in that conversation, so they are requested on the first
+ * press (like the comment manage menu). Mount with `key` per conversation.
+ */
+export function ConvoActionsMenu({ menu, onAction, style = 'glass' }: ConvoActionsMenuProps) {
+    const { t } = useTranslation();
+    const [actions, setActions] = useState<ConvoAction[] | null>(null);
+    const [loading, setLoading] = useState(false);
+
+    // Older UNA: no menu with the conversation, fixed actions.
+    const lazy = !!menu?.object;
+    const items = lazy ? actions : getConvoActions(null);
+
+    // The native menu opens from an effect keyed on `items` / `onSelect`: both stay
+    // stable across re-renders, or every header re-render would open it again.
+    const onActionRef = useRef(onAction);
+    useEffect(() => {
+        onActionRef.current = onAction;
+    }, [onAction]);
+
+    const menuItems = useMemo<DropdownMenuItemData[]>(
+        () => (items || []).map((item) => ({ ...item, title: t(item.title) })),
+        [items, t]
+    );
+
+    const handleSelect = useCallback((item: DropdownMenuItemData) => {
+        // Next press asks UNA again: toggles and rights may have changed.
+        if (lazy) setActions(null);
+        onActionRef.current(item as unknown as ConvoAction);
+    }, [lazy]);
+
+    const gear = (props: Record<string, unknown> = {}) => (
+        <NeoButton
+            style={style}
+            controlSize="regular"
+            borderShape="circle"
+            image="Settings"
+            accessibilityLabel={t('Settings')}
+            {...props}
+        />
+    );
+
+    if (!items) {
+        return gear({
+            loading,
+            onPress: async () => {
+                setLoading(true);
+                try {
+                    setActions(getConvoActions(await getDataForMenu(menu)));
+                } finally {
+                    setLoading(false);
+                }
+            },
+        });
+    }
+
+    if (!items.length)
+        return gear({ disabled: true });
+
+    return (
+        <DropdownMenu defaultOpen={lazy} onSelect={handleSelect} items={menuItems}>
+            {gear()}
+        </DropdownMenu>
+    );
+}
+
+type ConvoSearchButtonProps = {
+    open?: boolean;
+    onPress: () => void;
+    style?: string;
+};
+
+/** Toggles the search in the conversation's messages (pressed while open). */
+export function ConvoSearchButton({ open = false, onPress, style = 'glass' }: ConvoSearchButtonProps) {
+    const { t } = useTranslation();
+    return (
+        <NeoButton
+            style={style}
+            selected={open}
+            selectedState="pressedToggle"
+            controlSize="regular"
+            borderShape="circle"
+            image="Search"
+            accessibilityLabel={t('Search')}
+            onPress={onPress}
+        />
+    );
+}
+
+/** Search field that replaces the chat title while the search is open; reports the debounced text. */
+export function ConvoSearchField({ onSearch }: { onSearch: (term: string) => void }) {
+    const search = useDebouncedSearch(onSearch);
+    return <SearchInput value={search.value} onChangeText={search.setValue} autoFocus />;
+}
+
+type MobileListHeaderProps = {
+    pageData?: Record<string, any>;
+    onSearch: (term: string) => void;
+    addButtons?: AddButtons;
+    menuItems?: MessengerMenuItem[];
+    menuIndex: number;
+    onMenuChange: (index: number) => void;
+};
+
+/**
+ * Small screens, list mode: injected page header (stable "Messages" title +
+ * search + create, plus the inbox/direct switcher row). The switcher lives
+ * inside this component because PageHeader skips the `subHeader` slot when a
+ * custom `header` is injected.
+ */
+export function MobileListHeader({ pageData, onSearch, addButtons, menuItems, menuIndex, onMenuChange }: MobileListHeaderProps) {
+    const { t } = useTranslation();
+    const [showSearch, setShowSearch] = useState(false);
+    const search = useDebouncedSearch(onSearch);
+    const ContextSelector = registry['molecule']?.['context_selector'];
+    const listTitle = t('Messages');
+
+    // Header height comes from PageHeader's own onLayout, which also counts the
+    // edge-to-edge status bar inset. Measuring here would publish only this
+    // content's height and slide the first list row under the inbox/direct row.
+    return (
+        <View className="w-full">
+            <Row className={`${appSetting('layout', 'page_content_width_default')} ${messengerHeaderContentClass()} max-w-full`}>
+                {showSearch ? (
+                    <View className="flex-1 min-w-0 px-4">
+                        <SearchInput value={search.value} onChangeText={search.setValue} autoFocus />
+                    </View>
+                ) : (
+                    <Row className="flex-1 min-w-0 items-center gap-3 px-4 ">
+                        {appSetting('messenger', 'back_button') ? getBackButtonWeb() : null}
+                        {appSetting('context_selector', 'show_always') && ContextSelector ? (
+                            <ContextSelector url={pageData?.url} uri={pageData?.uri} data={pageData?.context} />
+                        ) : (
+                            <Text className="font-bold truncate flex-1 min-w-0 text-card-foreground text-2xl tracking-tight font-main" numberOfLines={1}>
+                                {listTitle}
+                            </Text>
+                        )}
+                    </Row>
+                )}
+                <Row className="my-auto shrink-0 items-center gap-3 pe-4">
+                    <NeoButton
+                        style="glass"
+                        selected={showSearch}
+                        selectedState="pressedToggle"
+                        controlSize="regular"
+                        borderShape="circle"
+                        image="Search"
+                        accessibilityLabel={t('Search')}
+                        onPress={() => {
+                            setShowSearch((prev) => {
+                                if (prev) search.reset();
+                                return !prev;
+                            });
+                        }}
+                    />
+                    {Array.isArray(addButtons)
+                        ? addButtons.map((button, i) => (button ? cloneElement(button, { key: button.key ?? i, style: 'glass' } as Record<string, unknown>) : null))
+                        : addButtons}
+                </Row>
+            </Row>
+            {menuItems && menuItems.length > 1 ? (
+                // Same box as the conductor tab row (web `conductor.menu_cnt`,
+                // native-ui TabBar), so pills sit at the same height under the title.
+                <Row className="w-full items-center px-4 min-h-13 lg:min-h-14 mb-0.5 lg:mb-0">
+                    <MenuSwitcher items={menuItems} index={menuIndex} onChange={onMenuChange} />
+                </Row>
+            ) : null}
+        </View>
+    );
+}
+
+type MobileChatHeaderProps = {
+    title?: string;
+    onBack: () => void;
+    menu?: ShortMenu;
+    menuKey?: string;
+    onAction: (action: ConvoAction) => void;
+    searchOpen?: boolean;
+    onSearchToggle?: () => void;
+    onSearch: (term: string) => void;
+};
+
+/** Small screens, chat mode: injected page header (back + title + actions). */
+export function MobileChatHeader({ title, onBack, menu, menuKey, onAction, searchOpen = false, onSearchToggle, onSearch }: MobileChatHeaderProps) {
+    const { t } = useTranslation();
+    return (
+        <Row className={`${appSetting('layout', 'page_content_width_default')} ${messengerHeaderContentClass()} max-w-full`}>
+            <Row className="flex-1 min-w-0 items-center gap-3 px-4 ">
+                <View className="shrink-0">
+                    <NeoButton
+                        style="glass"
+                        controlSize="regular"
+                        borderShape="circle"
+                        image="ArrowLeft"
+                        accessibilityLabel={t('Back')}
+                        onPress={onBack}
+                    />
+                </View>
+                {searchOpen ? (
+                    <View className="flex-1 min-w-0">
+                        <ConvoSearchField onSearch={onSearch} />
+                    </View>
+                ) : (
+                    <Text numberOfLines={1} className="flex-1 min-w-0 font-bold text-card-foreground text-lg sm:text-xl tracking-tight">
+                        {title}
+                    </Text>
+                )}
+            </Row>
+            <Row className="shrink-0 items-center justify-center gap-3 pe-4">
+                {onSearchToggle ? <ConvoSearchButton open={searchOpen} onPress={onSearchToggle} style="glass" /> : null}
+                <ConvoActionsMenu key={menuKey} menu={menu} onAction={onAction} style="glass" />
+            </Row>
+        </Row>
+    );
+}

+ 3 - 0
neo/packages/app/components/elements/messenger/parts/item-convo.js

@@ -15,6 +15,9 @@ export default function ({ item, index, changeConvo, selectedIndex }) {
             unread={item.unread}
             extraCount={participants.length > 1 ? participants.length - 1 : 0}
             selected={selectedIndex == index}
+            starred={item.starred == 1}
+            // Online dot for one-to-one chats only.
+            online={participants.length === 1 && participants[0]?.online == 1}
             onPress={() => changeConvo(item)}
         />
     );

+ 91 - 28
neo/packages/app/components/elements/messenger/parts/item-jot.js → neo/packages/app/components/elements/messenger/parts/item-jot.tsx

@@ -1,71 +1,118 @@
 import { View, Row } from 'app/design/view';
+import { Text } from 'app/design/typography';
 import { fetcher } from 'app/lib/fetcher';
-import React, { useState, useMemo, useCallback } from 'react';
+import { useState, useMemo, useCallback, type ComponentType } from 'react';
 import MessageItem from 'app/components/elements/chat/parts/message-item'
 import { NeoButton } from 'app/design/controls'
 import Form from 'app/components/elements/form';
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
-import DropdownMenu from 'app/ui/atoms/dropdown-menu';
+import DropdownMenu, { type DropdownMenuItemData } from 'app/ui/atoms/dropdown-menu';
 import { components } from 'app/components/registry';
 import { useTranslation } from 'react-i18next';
 import { FeedbackHaptics } from 'app/lib/util';
+import { playCue } from 'app/lib/sound';
+import { Icon } from 'app/ui/atoms/icon';
 import { toUnaDisplayImageItem } from 'app/lib/image-helpers'
-import Carousel from 'app/ui/molecules/content/carousel'
+import CarouselJs from 'app/ui/molecules/content/carousel'
 import { Modal } from 'app/design/controls'
 import emitter, { EVENTS } from 'app/context/emitter';
 import { TouchableWithoutFeedback } from 'react-native';
 import { ContentMore } from 'app/ui/molecules/content/content-more'
+import type { ApiMenuItem, Jot } from 'app/components/elements/messenger/parts/messenger.types';
 
-export default function JotItem({ item, index, handleReply }) {
-    const Reactions = components['molecule']['reactions'];
-    const { t } = useTranslation();
-    const [postData, setPostData] = useState(null)
-    const [viewState, setViewState] = useState({ view: '' })
+const registry = components as Record<string, Record<string, any> | undefined>;
+// carousel.js is untyped: `memo` of a JS component infers no props.
+const Carousel = CarouselJs as unknown as ComponentType<{ data: unknown[]; maxWidth?: number }>;
+
+type JotItemProps = {
+    item: Jot;
+    index?: number;
+    handleReply: (item: Jot) => void;
+    /** Above the first unread message: the 'new messages' line with this count. */
+    unreadCount?: number;
+    /** Saved / removed from saved by the viewer. */
+    onSavedChange?: (item: Jot, saved: boolean) => void;
+};
+
+type ViewState = { view: '' | 'edited'; data?: Record<string, any> };
 
-    const handleManageMenuSelect = useCallback(async (oItem, event) => {
+export default function JotItem({ item, handleReply, unreadCount = 0, onSavedChange }: JotItemProps) {
+    const Reactions = registry['molecule']?.['reactions'];
+    const { t } = useTranslation();
+    const [postData, setPostData] = useState<FormData | null>(null)
+    const [viewState, setViewState] = useState<ViewState>({ view: '' })
+    // Last save / remove from saved: the new state and UNA's item title for it. Kept with the
+    // message id, as a list can reuse this row for another message.
+    const [savedLocal, setSavedLocal] = useState<{ id: Jot['id']; saved: boolean; title?: string } | null>(null)
+    const savedForItem = savedLocal && savedLocal.id === item.id ? savedLocal : null
+    const isSaved = savedForItem ? savedForItem.saved : item.saved == 1
+    const saveTitle = savedForItem?.title || null
 
+    const handleManageMenuSelect = useCallback(async (oItem: DropdownMenuItemData & { name?: string }) => {
         switch (oItem.name) {
-            case 'edit':
+            case 'edit': {
                 const result = await fetcher('/api.php?r=bx_messenger/get_send_form/Services&params=' + JSON.stringify({ 'action': 'edit', id: item.id }));
                 setViewState({ view: 'edited', data: result });
                 break;
+            }
 
             case 'remove':
-                const result1 = await fetcher('/api.php?r=bx_messenger/remove_jot/Services&params=' + JSON.stringify({ jot_id: item.id, lot_id: item.lot_id, lot_hash: item.lot_hash }));
+                await fetcher('/api.php?r=bx_messenger/remove_jot/Services&params=' + JSON.stringify({ jot_id: item.id, lot_id: item.lot_id, lot_hash: item.lot_hash }));
                 break;
+
+            case 'save': {
+                const result = await fetcher('/api.php?r=bx_messenger/save_jot/Services&params=' + JSON.stringify({ jot: item.id }));
+                if (result?.data?.code == 0) {
+                    const saved = result.data.value == 1;
+                    playCue('toggle', { emphasis: 'subtle' });
+                    setSavedLocal({ id: item.id, saved, title: result.data.title || undefined });
+                    onSavedChange?.(item, saved);
+                }
+                else
+                    playCue('error');
+                break;
+            }
         }
-    }, [item]);
+    }, [item, onSavedChange]);
 
-    const { data: dynamicData } = useFetchForm('/api.php?r=bx_messenger/get_send_form/Services&params[]=', postData);
+    useFetchForm('/api.php?r=bx_messenger/get_send_form/Services&params[]=', postData);
 
-    let aImg = useMemo(
+    const aImg = useMemo(
         () => (item?.files || []).map(toUnaDisplayImageItem).filter(Boolean),
         [item]
     );
 
-    const onFormSubmit = useCallback((formData, d) => {
-        formData.set("id", item.lot_id);
+    const onFormSubmit = useCallback((formData: FormData) => {
+        formData.set("id", String(item.lot_id ?? ''));
         setViewState({ view: '' })
         setPostData(formData);
-    }, []);
+    }, [item.lot_id]);
 
-    const aManageMenu = useMemo(() => item.menu?.items.filter(item => ['remove', 'edit'].includes(item.name)), [item]);
+    const aManageMenu = useMemo<ApiMenuItem[]>(
+        () => (item.menu?.items || []).filter((menuItem) => ['save', 'remove', 'edit'].includes(menuItem.name)),
+        [item]
+    );
 
-    const handleReplyInner = useCallback(async (item) => {
+    const handleReplyInner = useCallback(async (jot: Jot) => {
         FeedbackHaptics('Medium');
         emitter.emit(EVENTS.editor, { action: 'focus' })
-        handleReply(item);
+        handleReply(jot);
     }, [handleReply]);
 
     const reactionsWithUpdatedParams = {
         ...item.reactions,
-        params: { ...item.reactions.params, button_size: "xs", button_variant: "link" }
+        params: { ...item.reactions?.params, button_size: "xs", button_variant: "link" }
     };
 
     const Jot = <TouchableWithoutFeedback onPress={() => { emitter.emit(EVENTS.editor, { action: 'blur' }) }}><View className='w-full'>
         <MessageItem
-            author={item.author_data}
+            author={item.author_data || {}}
             time={item.created}
+            badges={isSaved ? (
+                <View accessibilityLabel={t('Saved')}>
+                    <Icon icon="Bookmark" size={14} className="text-primary" />
+                </View>
+            ) : null}
             footer={
             <View className="flex-row justify-between items-center mt-0.5">
                 <View className="pl-12">
@@ -73,7 +120,7 @@ export default function JotItem({ item, index, handleReply }) {
                 </View>
                 <Row className='gap-2'>
                     <View className=''>
-                        <Reactions key={'reactions_' + item.id} {...reactionsWithUpdatedParams} />
+                        {Reactions ? <Reactions key={'reactions_' + item.id} {...reactionsWithUpdatedParams} /> : null}
                     </View>
                     {aManageMenu.length > 0 &&
                         <DropdownMenu items={aManageMenu.map((aItem) => {
@@ -81,7 +128,7 @@ export default function JotItem({ item, index, handleReply }) {
                                 id: aItem.id + '-' + aItem.name,
                                 name: aItem.name,
                                 link: aItem.link,
-                                title: aItem.title
+                                title: aItem.name === 'save' && saveTitle ? saveTitle : aItem.title
                             };
                         })} onSelect={handleManageMenuSelect}
                             buttonProps={{ style: 'borderless', controlSize: 'mini', borderShape: 'circle', image: 'Ellipsis', accessibilityLabel: t('More options') }} />
@@ -97,7 +144,6 @@ export default function JotItem({ item, index, handleReply }) {
                             onClose={() => {
                                 setViewState({ view: '' })
                             }}
-                            transparent={true}
                             headerBorder={true}
                         >
                             <View className="p-2">
@@ -113,10 +159,11 @@ export default function JotItem({ item, index, handleReply }) {
 
 
                     ) : <View className="pb-2">
-                        {item.reply > 0 ? (
+                        {Number(item.reply) > 0 ? (
                             <View className="border border-border/60 rounded-md p-2 my-1 bg-muted-foreground/20">
                                 <ContentMore
                                     content={item?.reply_message}
+                                    embed={undefined}
                                     numberOfLines={2}
                                     numberOfSymbols={200}
                                     openSmall={false}
@@ -126,6 +173,7 @@ export default function JotItem({ item, index, handleReply }) {
                         ) : null}
                         <ContentMore
                             content={item?.message}
+                            embed={undefined}
                             numberOfLines={3}
                             numberOfSymbols={360}
                             openSmall={false}
@@ -137,5 +185,20 @@ export default function JotItem({ item, index, handleReply }) {
         </MessageItem>
     </View></TouchableWithoutFeedback>
 
-    return Jot
-}
+    if (!unreadCount)
+        return Jot
+
+    // 'New messages' line above the first unread message of this visit.
+    return (
+        <View className='w-full'>
+            <Row className='w-full items-center gap-3 px-4 mt-4' accessibilityRole='header'>
+                <View className='flex-1 h-px bg-primary/60' />
+                <Text className='text-xs font-semibold text-primary'>
+                    {t('{{count}} new messages', { count: unreadCount })}
+                </Text>
+                <View className='flex-1 h-px bg-primary/60' />
+            </Row>
+            {Jot}
+        </View>
+    )
+}

+ 52 - 3
neo/packages/app/components/elements/messenger/parts/jots-panel.js → neo/packages/app/components/elements/messenger/parts/jots-panel.tsx

@@ -1,4 +1,4 @@
-import { memo, useMemo } from 'react';
+import { memo, useMemo, useState, type RefObject } from 'react';
 import { Platform } from 'react-native';
 import { Text } from 'app/design/typography';
 import { View } from 'app/design/view';
@@ -7,12 +7,52 @@ import ItemJot from 'app/components/elements/messenger/parts/item-jot';
 import { Icon } from 'app/ui/atoms/icon';
 import { ListInset } from 'app/components/elements/chat/parts/headers';
 import { useTranslation } from 'react-i18next';
+import type { Jot, UnreadInfo } from 'app/components/elements/messenger/parts/messenger.types';
 
 const isWeb = Platform.OS === 'web';
 const invertedNative = !isWeb;
 
+/**
+ * Where the 'new messages' line goes: before the first unread message, or before
+ * the first loaded one when the unread start is on an earlier page. Returns the
+ * row index (-1 for none) and the jot id the line is drawn above.
+ */
+function getUnreadStart(data: Jot[], unread?: UnreadInfo): { index: number; jotId: number } {
+    if (!unread?.count || !unread?.firstId || !Array.isArray(data) || !data.length) return { index: -1, jotId: 0 };
+
+    const index = data.findIndex((jot) => Number(jot?.id) >= unread.firstId);
+    const jot = data[index];
+    if (index < 0 || !jot) return { index: -1, jotId: 0 };
+
+    return { index, jotId: Number(jot.id) };
+}
+
+type JotsPanelProps = {
+    data: Jot[];
+    unread?: UnreadInfo;
+    listHeight?: number;
+    refListJots: RefObject<any>;
+    startReached: () => void;
+    handleReply: (item: Jot) => void;
+    /** Saved / removed from saved by the viewer (the Saved section drops removed ones). */
+    onSavedChange?: (item: Jot, saved: boolean) => void;
+    isSmallScreen: boolean;
+    headerInset?: number;
+    footerInset?: number;
+    keyboardBottomOffset?: number;
+};
+
 /** Message list of the open conversation. Desktop chrome overlays the parent; mobile web window-scrolls. */
-const JotsPanel = memo(function JotsPanel({ data, listHeight, refListJots, startReached, handleReply, isSmallScreen, headerInset = 0, footerInset = 0, keyboardBottomOffset }) {
+const JotsPanel = memo(function JotsPanel({ data, unread, listHeight, refListJots, startReached, handleReply, onSavedChange, isSmallScreen, headerInset = 0, footerInset = 0, keyboardBottomOffset }: JotsPanelProps) {
+    // Taken once when the talk opens: the line keeps its place while new messages arrive.
+    const [unreadStart] = useState(() => getUnreadStart(data, unread));
+    // Open on the line, one message above it for context, instead of the end.
+    const initialPosition = useMemo(() => {
+        if (unreadStart.index < 0) return {};
+        const index = Math.max(0, unreadStart.index - 1);
+        return isWeb ? { initialTopMostItemIndex: { index, align: 'start' } } : { initialScrollIndex: index };
+    }, [unreadStart]);
+
     const listOverlayProps = useMemo(() => {
         if (isWeb) {
             return {
@@ -54,8 +94,17 @@ const JotsPanel = memo(function JotsPanel({ data, listHeight, refListJots, start
                 mode="simple"
                 useCustomScrollHandler={isSmallScreen && !isWeb}
                 keyboardBottomOffset={keyboardBottomOffset}
-                renderItem={({ item, index }) => <ItemJot handleReply={handleReply} item={item} index={index} />}
+                renderItem={({ item, index }: { item: Jot; index: number }) => (
+                    <ItemJot
+                        handleReply={handleReply}
+                        onSavedChange={onSavedChange}
+                        item={item}
+                        index={index}
+                        unreadCount={Number(item?.id) === unreadStart.jotId ? unread?.count : 0}
+                    />
+                )}
                 {...listOverlayProps}
+                {...initialPosition}
             />
         </View>
     );

+ 99 - 0
neo/packages/app/components/elements/messenger/parts/messenger.types.ts

@@ -0,0 +1,99 @@
+import type { ReactElement } from 'react';
+
+/** UNA API payloads are loosely shaped; known fields are listed, the rest stays open. */
+type Loose = Record<string, any>;
+
+/** Profile data (`BxDolProfile::getData`): author, participant. */
+export type ProfileData = Loose & {
+    id?: number | string;
+    display_name?: string;
+    url?: string;
+    url_avatar?: string;
+    /** With `with_online`: 1 when online. */
+    online?: number;
+};
+
+/** Short menu (`getShortCodeAPI`): items are requested with `system/get_menu`. */
+export type ShortMenu = Loose & {
+    object?: string;
+    title?: string;
+    params?: Loose;
+};
+
+/** UNA menu item (`getCodeAPI`). */
+export type ApiMenuItem = Loose & {
+    id?: number | string;
+    name: string;
+    title?: string;
+    icon?: string;
+    /** Toggles (mute, star): current state. */
+    value?: number | string;
+};
+
+export type ApiMenu = Loose & {
+    object?: string;
+    items?: ApiMenuItem[];
+};
+
+/** One conversation of the list (`_unitLot`). */
+export type Convo = Loose & {
+    /** Hash, used in URLs and socket channels. */
+    id: string;
+    /** Numeric lot id, used by the services. */
+    id2: number | string;
+    title?: string;
+    message?: string;
+    date?: number;
+    unread?: number;
+    starred?: number;
+    participants: ProfileData[];
+    author_data?: ProfileData;
+    menu?: ShortMenu;
+};
+
+/** One message (`serviceGetConvoMessages`). */
+export type Jot = Loose & {
+    id: number | string;
+    message?: string;
+    created?: number;
+    lot_id?: number | string;
+    lot_hash?: string;
+    reply?: number | string;
+    reply_message?: string;
+    saved?: number;
+    files?: Loose[];
+    menu?: ApiMenu;
+    reactions?: Loose;
+    author_data?: ProfileData;
+};
+
+/** Unread on opening a talk: the 'new messages' line. */
+export type UnreadInfo = {
+    count: number;
+    /** Id of the first unread message. */
+    firstId: number;
+};
+
+/** Messages of the open talk. */
+export type JotsState = {
+    data: {
+        params?: Loose;
+        jots: Jot[];
+    };
+    index?: number;
+    unread?: UnreadInfo;
+};
+
+/** A conversation action NEO handles (gear menu). */
+export type ConvoAction = {
+    id: string;
+    title: string;
+    icon: string;
+    iconOn?: string;
+    value?: boolean;
+};
+
+/** Inbox / direct / ... tab of the conversations list. */
+export type MessengerMenuItem = Loose & { name: string; title: string };
+
+export type AddButtons = ReactElement[] | ReactElement | null | undefined;

+ 1 - 1
neo/packages/app/components/elements/messenger/parts/new-convo.js

@@ -50,7 +50,7 @@ export default function CreateConvo({ onSave, initedData = [], convoId }) {
             <Row>
                 <FormError errorText={message} />
             </Row>
-            {!loading && <SelectUsers onlyOnce={false} onSave={handleSave} requestUrl={'/api.php?r=bx_messenger/search_users/Services&params='} initedData={initedData} extraSuggestions={agents} />}
+            {!loading && <SelectUsers autoFocus onlyOnce={false} onSave={handleSave} requestUrl={'/api.php?r=bx_messenger/search_users/Services&params='} initedData={initedData} extraSuggestions={agents} />}
             {loading && <View className='w-full pt-8 items-center'>
                 <Loading />
                 <Text className="pt-8 text-base text-muted-foreground  animate-pulse  font-medium">{t('messenger_creating')}</Text>

+ 2 - 1
neo/packages/app/components/form-fields/initial-members.js

@@ -55,7 +55,7 @@ export const User = ({ data, onSelect, type }) => {
 
 const EMPTY_SUGGESTIONS = [];
 
-export function SelectUsers({ onSave, initedData = [], requestUrl, isSingle = false, extraSuggestions = EMPTY_SUGGESTIONS }) {
+export function SelectUsers({ onSave, initedData = [], requestUrl, isSingle = false, extraSuggestions = EMPTY_SUGGESTIONS, autoFocus = false }) {
 
     const { t } = useTranslation();
     const extraList = Array.isArray(extraSuggestions) ? extraSuggestions : EMPTY_SUGGESTIONS;
@@ -145,6 +145,7 @@ export function SelectUsers({ onSave, initedData = [], requestUrl, isSingle = fa
                     className="px-2 mr-2  w-full"
                     onChangeText={onChangeText}
                     role="textbox"
+                    autoFocus={autoFocus}
                 />
                 <NeoButton style="borderedProminent" controlSize="large" image="Check" accessibilityLabel={t('Save')} disabled={state.selectedUsers.length == 0} onPress={() => onSaveInt()} />
             </Row>