Преглед изворни кода

Merge branch 'master' into design-fixes

Resolves the common.css conflict: both sides moved the anchor and button
outline suppression to :focus:not(:focus-visible); master's version is kept
and the duplicated anchor rule from the branch is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Andrey Yasko пре 1 месец
родитељ
комит
04ad9d62b4

+ 10 - 6
.github/workflows/ci.yml

@@ -15,14 +15,21 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
+
+    - name: Setup PHP
+      uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
+      with:
+        php-version: '8.3'
+        extensions: intl, zip
+        tools: composer, phing:3.1.2
 
     - name: Validate composer.json and composer.lock
       run: composer validate
 
     - name: Cache Composer packages
       id: composer-cache
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: plugins
         key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
@@ -32,10 +39,7 @@ jobs:
       run: composer install --prefer-dist --no-progress
 
     - name: Package zip
-      uses: phingofficial/phing-github-action@main
-      with:
-        targets: package
-        user-properties: package_file=UNA.zip
+      run: phing package -Dpackage_file=UNA.zip
 
     # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit"
     # Docs: https://getcomposer.org/doc/articles/scripts.md

+ 26 - 19
inc/classes/BxDolCmts.php

@@ -1580,7 +1580,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
          *      - `cf` - [int] comment's audience filter value
          * @hook @ref hook-bx_dol_comment-commentRemoved
          */
-        bx_alert($this->_sSystem, 'commentRemoved', $iObjId, $iPerformerId, $aAlertParams);
+        $this->_callAlert($this->_sSystem, 'commentRemoved', $iObjId, $iPerformerId, $aAlertParams);
         
         /**
          * @hooks
@@ -1589,7 +1589,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
          * except 'comment id' is provided in $object_id
          * @hook @ref hook-comment-deleted
          */
-        bx_alert('comment', 'deleted', $iCmtId, $iPerformerId, $aAlertParams);
+        $this->_callAlert('comment', 'deleted', $iCmtId, $iPerformerId, $aAlertParams);
 
         if(!empty($iCmtPrntId)) {
             $aCmtPrnt = $this->_oQuery->getCommentSimple($iObjId, $iCmtPrntId);
@@ -1619,7 +1619,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
                  *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
                  * @hook @ref hook-bx_dol_comment-replyRemoved
                  */
-                bx_alert($this->_sSystem, 'replyRemoved', $iCmtPrntId, $iPerformerId, $aAlertParamsReply);
+                $this->_callAlert($this->_sSystem, 'replyRemoved', $iCmtPrntId, $iPerformerId, $aAlertParamsReply);
                 
                 /**
                  * @hooks
@@ -1628,7 +1628,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
                  * except 'comment id' is provided in $object_id
                  * @hook @ref hook-reply-deleted
                  */
-                bx_alert('reply', 'deleted', $iCmtId, $iPerformerId, $aAlertParamsReply);
+                $this->_callAlert('reply', 'deleted', $iCmtId, $iPerformerId, $aAlertParamsReply);
             }
         }
 
@@ -1771,7 +1771,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
          *      - `cf` - [int] comment's audience filter value
          * @hook @ref hook-bx_dol_comment-commentPost
          */
-        bx_alert($this->_sSystem, 'commentPost', $iObjId, $iPerformerId, $aAlertParams);
+        $this->_callAlert($this->_sSystem, 'commentPost', $iObjId, $iPerformerId, $aAlertParams);
         
         /**
          * @hooks
@@ -1780,7 +1780,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
          * except 'comment id' is provided in $object_id
          * @hook @ref hook-comment-added
          */
-        bx_alert('comment', 'added', $iCmtId, $iPerformerId, $aAlertParams);
+        $this->_callAlert('comment', 'added', $iCmtId, $iPerformerId, $aAlertParams);
 
         $aAuditParams = $this->_prepareAuditParams($iCmtId, array('comment_author_id' => $aCmt['cmt_author_id'], 'comment_text' => $aCmt['cmt_text']));
         bx_audit($iObjId, $this->_aSystem['module'], '_sys_audit_action_add_comment', $aAuditParams);
@@ -1813,7 +1813,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
                  *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
                  * @hook @ref hook-bx_dol_comment-replyPost
                  */
-                bx_alert($this->_sSystem, 'replyPost', $iCmtPrntId, $iPerformerId, $aAlertParamsReply);
+                $this->_callAlert($this->_sSystem, 'replyPost', $iCmtPrntId, $iPerformerId, $aAlertParamsReply);
                 
                 /**
                  * @hooks
@@ -1822,7 +1822,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
                  * except 'comment id' is provided in $object_id
                  * @hook @ref hook-comment-replied
                  */
-                bx_alert('comment', 'replied', $iCmtId, $iPerformerId, $aAlertParamsReply);
+                $this->_callAlert('comment', 'replied', $iCmtId, $iPerformerId, $aAlertParamsReply);
             }
         }
 
@@ -1885,7 +1885,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
          *      - `cf` - [int] comment's audience filter value
          * @hook @ref hook-bx_dol_comment-commentUpdated
          */
-        bx_alert($this->_sSystem, 'commentUpdated', $iObjId, $iPerformerId, $aAlertParams);
+        $this->_callAlert($this->_sSystem, 'commentUpdated', $iObjId, $iPerformerId, $aAlertParams);
         
         /**
          * @hooks
@@ -1894,7 +1894,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
          * except 'comment id' is provided in $object_id
          * @hook @ref hook-comment-edited
          */
-        bx_alert('comment', 'edited', $iCmtId, $iPerformerId, $aAlertParams);
+        $this->_callAlert('comment', 'edited', $iCmtId, $iPerformerId, $aAlertParams);
 
         $aAuditParams = $this->_prepareAuditParams($iCmtId, ['comment_author_id' => $aCmt['cmt_author_id'], 'comment_text' => $aCmt['cmt_text']]);
         bx_audit($iObjId, $this->_aSystem['module'], '_sys_audit_action_edit_comment', $aAuditParams);
@@ -2200,6 +2200,11 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
         $aDp['show_empty'] = isset($aDp['show_empty']) ? (bool)$aDp['show_empty'] : false;
     }
 
+    protected function _callAlert($sUnit, $sAction, $iObjectId, $iSender = false, $aExtras = [])
+    {
+        return bx_alert($sUnit, $sAction, $iObjectId, $iSender, $aExtras);
+    }
+
     protected function _prepareAlertParams($aCmt)
     {
         $iObjId = (int)$this->getId();
@@ -2211,7 +2216,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
         $iCmtAthrId = (int)$aCmt['cmt_author_id'];
         $iCmtCf = isset($aCmt['cmt_cf']) ? (int)$aCmt['cmt_cf'] : BxDolContentFilter::getInstance()->getDefaultValue();
 
-        return array(
+        return [
             'source' => 'sys_cmts_' . $iCmtUniqId,
             'source_mac' => 'sys_cmts_' . $iCmtAthrId . '_' . $iCmtUniqId,
 
@@ -2227,7 +2232,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
 
             'privacy_view' => $iObjAthrPrivacyView,
             'cf' => $iCmtCf
-        );
+        ];
     }
 
     protected function _prepareAlertParamsReply($aCmt, $aCmtPrnt)
@@ -2241,12 +2246,14 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
 
         $iCmtId = (int)$aCmt['cmt_id'];
         $iCmtUniqId = $this->getCommentUniqId($iCmtId);
+        $iCmtAthrId = (int)$aCmt['cmt_author_id'];
 
-        return array(
+        return [
             'source' => 'sys_cmts_' . $iCmtUniqId,
+            'source_mac' => 'sys_cmts_' . $iCmtAthrId . '_' . $iCmtUniqId,
 
-            'object_system' => $this->_sSystem, 
-            'object_id' => $iObjId, 
+            'object_system' => $this->_sSystem,
+            'object_id' => $iObjId,
             'object_author_id' => $iObjAthrId,
 
             'parent_id' => $iCmtPrntId,
@@ -2255,11 +2262,11 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
 
             'comment_id' => $iCmtId,
             'comment_uniq_id' => $iCmtUniqId,
-            'comment_author_id' => $aCmt['cmt_author_id'],  
+            'comment_author_id' => $iCmtAthrId,
             'comment_text' => $aCmt['cmt_text'],
 
             'privacy_view' => $iObjAthrPrivacyView,
-        );
+        ];
     }
 
     protected function _prepareAuditParams($iId, $aData)
@@ -2669,7 +2676,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
          *      - `data_api` - [array] by ref, comment data prepared for sending in API response, can be overridden in hook processing
          * @hook @ref hook-system-decode_comment_data_api
          */
-        bx_alert('system', 'decode_comment_data_api', 0, 0, $aExtras);
+        $this->_callAlert('system', 'decode_comment_data_api', 0, 0, $aExtras);
         
         /**
          * @hooks
@@ -2677,7 +2684,7 @@ class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContent
          * It's equivalent to @ref hook-system-decode_comment_data_api
          * @hook @ref hook-bx_dol_comment-decode_comment_data_api
          */
-        bx_alert($sModule, 'decode_comment_data_api', 0, 0, $aExtras);
+        $this->_callAlert($sModule, 'decode_comment_data_api', 0, 0, $aExtras);
 
         return $aDataApi;
     }

+ 8 - 1
inc/classes/BxDolInformer.php

@@ -78,8 +78,12 @@ class BxDolInformer extends BxDolFactory implements iBxDolSingleton
      * @param $sId - message id
      * @param $sMsg - message text
      * @param $iType - message type: BX_INFORMER_ALERT, BX_INFORMER_INFO or BX_INFORMER_ERROR
+     * @param $aParams - optional params:
+     *      - `icon` - [string] font icon name to show instead of the default type icon, e.g. `user-plus`
+     *      - `action_url` - [string] URL of the trailing action button
+     *      - `action_title` - [string] title of the trailing action button, the button is shown only when both URL and title are provided
      */
-    public function add ($sId, $sMsg, $iType = BX_INFORMER_INFO)
+    public function add ($sId, $sMsg, $iType = BX_INFORMER_INFO, $aParams = [])
     {
         if(!$this->_bEnabled)
             return;
@@ -89,6 +93,9 @@ class BxDolInformer extends BxDolFactory implements iBxDolSingleton
             'id' => $sId,
             'msg' => $sMsg,
             'type' => $iType,
+            'icon' => !empty($aParams['icon']) ? $aParams['icon'] : '',
+            'action_url' => !empty($aParams['action_url']) ? $aParams['action_url'] : '',
+            'action_title' => !empty($aParams['action_title']) ? $aParams['action_title'] : '',
         ];
     }
 

+ 5 - 2
inc/classes/BxDolProfile.php

@@ -839,8 +839,11 @@ class BxDolProfile extends BxDolFactory implements iBxDolProfile
     	$aProfiles = $this->_oQuery->getProfilesByAccount($aInfo['account_id']);
 
         if ($aInfo['type'] == 'system' && count($aProfiles) == 1) {
-            $sUrl = BxDolPermalinks::getInstance()->permalink('page.php?i=account-profile-switcher');
-            $oInformer->add('sys-account-profile-system', _t('_sys_txt_account_profile_system', $sUrl), BX_INFORMER_ALERT);
+            $oInformer->add('sys-account-profile-system', _t('_sys_txt_account_profile_system'), BX_INFORMER_INFO, [
+                'icon' => 'user-plus',
+                'action_url' => BxDolPermalinks::getInstance()->permalink('page.php?i=account-profile-switcher'),
+                'action_title' => _t('_sys_txt_account_profile_system_action'),
+            ]);
         }
     }
 }

+ 11 - 8
inc/classes/BxDolScore.php

@@ -258,6 +258,11 @@ class BxDolScore extends BxDolObjectVote
         $this->_trigger();
 
         $sTypeUc = ucfirst($sType);
+        $aAlertExtras = [
+            'object_author_id' => $iObjectAuthorId,
+            'source' => 'sys_score_' . $iId,
+            'source_mac' => 'sys_score_' . $iAuthorId . '_' . $iId,
+        ];
         /**
          * @hooks
          * @hookdef hook-bx_dol_score-doVoteUp '{object_name}', 'doVoteUp' - hook after score vote 
@@ -289,11 +294,10 @@ class BxDolScore extends BxDolObjectVote
          * It's equivalent to @ref hook-bx_dol_score-doVoteUp
          * @hook @ref hook-bx_dol_score-undoVoteDown
          */
-        bx_alert($this->_sSystem, ($bPerformUndo ? 'un' : '') . 'doVote' . $sTypeUc, $iObjectId, $iAuthorId, [
+        bx_alert($this->_sSystem, ($bPerformUndo ? 'un' : '') . 'doVote' . $sTypeUc, $iObjectId, $iAuthorId, array_merge([
             'score_id' => $iId, 
-            'score_author_id' => $iAuthorId, 
-            'object_author_id' => $iObjectAuthorId
-        ]);
+            'score_author_id' => $iAuthorId
+        ], $aAlertExtras));
 
         /**
          * @hooks
@@ -326,11 +330,10 @@ class BxDolScore extends BxDolObjectVote
          * It's equivalent to @ref hook-score-doUp
          * @hook @ref hook-score-undoDown
          */
-        bx_alert('score', ($bPerformUndo ? 'un' : '') . 'do' . $sTypeUc, $iId, $iAuthorId, [
+        bx_alert('score', ($bPerformUndo ? 'un' : '') . 'do' . $sTypeUc, $iId, $iAuthorId, array_merge([
             'object_system' => $this->_sSystem, 
-            'object_id' => $iObjectId, 
-            'object_author_id' => $iObjectAuthorId
-        ]);
+            'object_id' => $iObjectId
+        ], $aAlertExtras));
 
         $aRequestParamsData['show_script'] = false;
 

+ 13 - 3
inc/classes/BxDolVote.php

@@ -233,6 +233,11 @@ class BxDolVote extends BxDolObjectVote
 
         $this->_trigger();
 
+        $aAlertExtras = [
+            'object_author_id' => $iObjectAuthorId,
+            'source' => 'sys_vote_' . $iId,
+            'source_mac' => 'sys_vote_' . $iAuthorId . '_' . $iId,
+        ];
         /**
          * @hooks
          * @hookdef hook-vote-undo 'vote', 'undo' - hook on cancel vote 
@@ -246,7 +251,10 @@ class BxDolVote extends BxDolObjectVote
          *      - `object_author_id` - [int] author's profile_id for reported object_id 
          * @hook @ref hook-vote-undo
          */
-        bx_alert($this->_sSystem, ($bPerformUndo ? 'un' : '') . 'doVote', $iObjectId, $iAuthorId, array_merge(['vote_id' => $iId, 'vote_author_id' => $iAuthorId, 'object_author_id' => $iObjectAuthorId], $aVoteData));
+        bx_alert($this->_sSystem, ($bPerformUndo ? 'un' : '') . 'doVote', $iObjectId, $iAuthorId, array_merge([
+            'vote_id' => $iId, 
+            'vote_author_id' => $iAuthorId
+        ], $aAlertExtras, $aVoteData));
         /**
          * @hooks
          * @hookdef hook-vote-do 'vote', 'do' - hook on new vote 
@@ -260,8 +268,10 @@ class BxDolVote extends BxDolObjectVote
          *      - `object_author_id` - [int] author's profile_id for reported object_id 
          * @hook @ref hook-vote-do
          */
-        
-        bx_alert('vote', ($bPerformUndo ? 'un' : '') . 'do', $iId, $iAuthorId, array_merge(['object_system' => $this->_sSystem, 'object_id' => $iObjectId, 'object_author_id' => $iObjectAuthorId], $aVoteData));
+        bx_alert('vote', ($bPerformUndo ? 'un' : '') . 'do', $iId, $iAuthorId, array_merge([
+            'object_system' => $this->_sSystem, 
+            'object_id' => $iObjectId
+        ], $aAlertExtras, $aVoteData));
 
         $aResult = $this->_returnVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, !$bVoted, $aRequestParamsData);
 

+ 28 - 15
modules/base/groups/classes/BxBaseModGroupsModule.php

@@ -530,6 +530,8 @@ class BxBaseModGroupsModule extends BxBaseModProfileModule
         }
 
         $sModule = $this->getName();
+        $sSource = $CNF['OBJECT_CONNECTIONS'];
+        $iSourceTime = time();
 
         /**
          * @hooks
@@ -555,7 +557,7 @@ class BxBaseModGroupsModule extends BxBaseModProfileModule
             'group_profile' => $iContextPid, 
             'profile' => $iPid, 
             'notification_subobject_id' => $iPid, 
-            'object_author_id' => $iContextPid
+            'object_author_id' => $iContextPid,
         ]);
 
         /**
@@ -578,6 +580,9 @@ class BxBaseModGroupsModule extends BxBaseModProfileModule
         bx_alert($sModule, 'join_invitation_notif', $aContentInfo[$CNF['FIELD_ID']], $iContextPid, [
             'object_author_id' => $iPid, 
             'privacy_view' => isset($aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]) ? $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] : 3, 
+
+            'source' => $sSource . '_invitation_' . $iSourceTime,
+            'source_mac' => $sSource . '_invitation_' . $iPerformerPid . '_' . $iSourceTime,
         ]);
 
         return true;
@@ -608,6 +613,8 @@ class BxBaseModGroupsModule extends BxBaseModProfileModule
 
         $sModule = $this->getName();
         $sModuleGroup = $oGroupProfile->getModule();
+        $sSource = $CNF['OBJECT_CONNECTIONS'];
+        $iSourceTime = time();
 
         // send notification to group's admins that new connection is pending confirmation 
         if($oConnection->isConnected((int)$iInitiatorId, $oGroupProfile->id()) && !$oConnection->isConnected($oGroupProfile->id(), (int)$iInitiatorId) && $aContentInfo['join_confirmation'] && $aContentInfo[$CNF['FIELD_AUTHOR']] != $iProfileId) {
@@ -629,15 +636,18 @@ class BxBaseModGroupsModule extends BxBaseModProfileModule
              * @hook @ref hook-bx_base_groups-join_request
              */
             bx_alert($this->getName(), 'join_request', $aContentInfo[$CNF['FIELD_ID']], $iGroupProfileId, [
-            	'object_author_id' => $iGroupProfileId,
-            	'performer_id' => $iProfileId, 
+                'object_author_id' => $iGroupProfileId,
+                'performer_id' => $iProfileId, 
+
+                'content' => $aContentInfo, 
+                'entry_title' => $sEntryTitle, 
+                'entry_url' => $sEntryUrl, 
 
-            	'content' => $aContentInfo, 
-            	'entry_title' => $sEntryTitle, 
-            	'entry_url' => $sEntryUrl, 
+                'group_profile' => $iGroupProfileId, 
+                'profile' => $iProfileId,
 
-            	'group_profile' => $iGroupProfileId, 
-            	'profile' => $iProfileId
+                'source' => $sSource . '_request_' . $iSourceTime,
+                'source_mac' => $sSource . '_request_' . $iPerformerId . '_' . $iSourceTime,
             ]);
         }
         // send notification that join request was accepted 
@@ -649,15 +659,15 @@ class BxBaseModGroupsModule extends BxBaseModProfileModule
              * @hook @ref hook-bx_base_groups-join_request_accepted
              */
             bx_alert($this->getName(), 'join_request_accepted', $aContentInfo[$CNF['FIELD_ID']], $iGroupProfileId, [
-            	'object_author_id' => $iGroupProfileId,
-            	'performer_id' => $iProfileId,
+                'object_author_id' => $iGroupProfileId,
+                'performer_id' => $iProfileId,
 
-            	'content' => $aContentInfo, 
-            	'entry_title' => $sEntryTitle, 
-            	'entry_url' => $sEntryUrl, 
+                'content' => $aContentInfo, 
+                'entry_title' => $sEntryTitle, 
+                'entry_url' => $sEntryUrl, 
 
-            	'group_profile' => $iGroupProfileId, 
-            	'profile' => $iProfileId
+                'group_profile' => $iGroupProfileId, 
+                'profile' => $iProfileId
             ]);
         }
 
@@ -686,6 +696,9 @@ class BxBaseModGroupsModule extends BxBaseModProfileModule
 
                     'group_profile' => $iGroupProfileId, 
                     'profile' => $iProfileId,
+
+                    'source' => $sSource . '_added_' . $iSourceTime,
+                    'source_mac' => $sSource . '_added_' . $iPerformerId . '_' . $iSourceTime,
                 ]);
             }
 

+ 2 - 1
modules/boonex/english/data/langs/system/en.xml

@@ -4266,7 +4266,8 @@ If it is not enabled then please consider implementing this optimization, since
     <string name="_sys_txt_account_suspended"><![CDATA[Your account is suspended.]]></string>
     <string name="_sys_txt_account_unconfirmed_email"><![CDATA[Your email address is unconfirmed. Please, check your email ({1}) for a confirmation letter and click email confirmation link or use confirmation code (6 digit) to <a href="{2}">confirm your email</a>. Can't find the letter? Check your SPAM folder or <a href="{0}">send the verification letter again</a>.]]></string>
     <string name="_sys_txt_account_unconfirmed_phone"><![CDATA[Your phone is unconfirmed. Please, confirm it on <a href="{0}">verification page</a>.]]></string>
-    <string name="_sys_txt_account_profile_system"><![CDATA[Please <a href="{0}">create a profile</a> to start using the site.]]></string>
+    <string name="_sys_txt_account_profile_system"><![CDATA[Your account doesn't have a profile yet. Create one to start using the site.]]></string>
+    <string name="_sys_txt_account_profile_system_action"><![CDATA[Create profile]]></string>
     <string name="_sys_txt_account_need_to_change_password"><![CDATA[Your password has been expired. Please <a href="{0}">change your password</a>.]]></string>
     <string name="_sys_txt_account_cannot_delete"><![CDATA[Cannot delete the only Operator account]]></string>
     <string name="_sys_txt_account_deletion_scheduled"><![CDATA[Account deletion was scheduled and would be done in a few minutes.]]></string>

+ 1 - 1
modules/boonex/notifications/classes/BxNtfsConfig.php

@@ -168,7 +168,7 @@ class BxNtfsConfig extends BxBaseModNotificationsConfig
         $this->_iDeliveryTimeout = (int)getParam($sOptionPrefix . 'delivery_timeout');
 
         $this->_bEventsGrouped = getParam($sOptionPrefix . 'enable_group_events') == 'on';
-        $this->_bEventsGroupedDb = getParam($sOptionPrefix . 'enable_group_events_db') == 'on';;
+        $this->_bEventsGroupedDb = getParam($sOptionPrefix . 'enable_group_events_db') == 'on';
 
         $this->_bClickedIndicator = getParam($sOptionPrefix . 'enable_clicked_indicator') == 'on';
 

+ 96 - 34
modules/boonex/notifications/classes/BxNtfsCronNotify.php

@@ -19,6 +19,8 @@ class BxNtfsCronNotify extends BxDolCron
     protected $_sModule;
     protected $_oModule;
 
+    protected $_bOwnActions;
+    protected $_bEventsGroupedDb;
     protected $_bDeliveryTimeout;
 
     public function __construct()
@@ -28,6 +30,10 @@ class BxNtfsCronNotify extends BxDolCron
 
         parent::__construct();
 
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $this->_bOwnActions = getParam($CNF['PARAM_OWN_ACTIONS']) == 'on';
+        $this->_bEventsGroupedDb = $this->_oModule->_oConfig->isEventsGroupedDb();
         $this->_bDeliveryTimeout = $this->_oModule->_oConfig->getDeliveryTimeout() > 0;
     }
 
@@ -42,22 +48,47 @@ class BxNtfsCronNotify extends BxDolCron
         }
 
         $aEvents = $this->_oModule->_oDb->getEventsToProcess((int)getParam($CNF['PARAM_QUEUE_ADD_LIMIT']));
-
         foreach($aEvents as $aEvent) {
             if(!empty($aEvent['content']) && is_string($aEvent['content']))
                 $aEvent['content'] = unserialize($aEvent['content']);
 
-            $this->_sendNotifications($aEvent);
+            $this->_processNotifications($aEvent);
         }
+
+        if($this->_bEventsGroupedDb && ($aProfiles = $this->_oModule->_oDb->aggregatorGetProfiles()) && is_array($aProfiles))
+            foreach($aProfiles as $iProfile) {
+                $aEventsIds = [];
+                foreach([BX_BASE_MOD_NTFS_DTYPE_EMAIL, BX_BASE_MOD_NTFS_DTYPE_PUSH] as $sDeliveryType) {
+                    $sMethodGet = 'getNotification' . bx_gen_method_name($sDeliveryType);
+
+                    $aEvents = $this->_oModule->_oDb->aggregatorGetEvents($iProfile, $sDeliveryType);
+                    foreach($aEvents as $aEvent) {
+                        if(($sGroupedByMac = $aEvent['grouped_by_mac'] ?? false))
+                            $aEventsIds = array_merge($aEventsIds,  explode(',', $sGroupedByMac));
+
+                        $sEvent = $this->_oModule->_oTemplate->getPost($aEvent, ['perform_privacy_check_for' => $iProfile, 'show_real_profile' => false]);
+                        if(empty($sEvent) || empty($aEvent['content_parsed']))
+                            continue;
+
+                        $mixedNotification = false;
+                        if(($mixedNotification = $this->_oModule->_oTemplate->$sMethodGet($iProfile, $aEvent)) === false)
+                            continue;
+
+                        $this->_sendNotification($iProfile, $aEvent['id'], $sDeliveryType, $mixedNotification);
+                    }
+                }
+
+                $this->_oModule->_oDb->aggregatorDelete($iProfile, array_unique($aEventsIds));
+            }
     }
 
-    protected function _sendNotifications(&$aEvent)
+    protected function _processNotifications(&$aEvent)
     {
         $aHandler = $this->_oModule->_oConfig->getHandlers($aEvent['type'] . '_' . $aEvent['action']);
         if(empty($aHandler) || !is_array($aHandler))
             return;
 
-        $aDeliveryTypes = array();
+        $aDeliveryTypes = [];
 
         $iId = (int)$aEvent['id'];
         $iSilentMode = $this->_oModule->getSilentMode($aEvent['content']);
@@ -76,32 +107,28 @@ class BxNtfsCronNotify extends BxDolCron
                 break;
 
             default:
-                $aDeliveryTypes = array(BX_BASE_MOD_NTFS_DTYPE_EMAIL, BX_BASE_MOD_NTFS_DTYPE_PUSH);
+                $aDeliveryTypes = [BX_BASE_MOD_NTFS_DTYPE_EMAIL, BX_BASE_MOD_NTFS_DTYPE_PUSH];
         }
 
-        $aSendUsing = array();
+        $aSendUsing = [];
         foreach($aDeliveryTypes as $sDeliveryType) {
             $aHidden = $this->_oModule->_oConfig->getHandlersHidden($sDeliveryType);
             if(in_array($aHandler['id'], $aHidden))
                 continue;
 
-            $sMethodPostfix = bx_gen_method_name($sDeliveryType);
-            $sMethodGet = 'getNotification' . $sMethodPostfix;
-            $sMethodSend = 'sendNotification' . $sMethodPostfix;
-            if(!$this->_oModule->_oTemplate->isMethodExists($sMethodGet) || !method_exists($this->_oModule, $sMethodSend))
+            $sMethod = 'Notification' . bx_gen_method_name($sDeliveryType);
+            $sMethodGet = 'get' . $sMethod;
+            if(!$this->_oModule->_oTemplate->isMethodExists($sMethodGet) || !method_exists($this->_oModule, 'send' . $sMethod))
                 continue;
 
-            $aSendUsing[$sDeliveryType] = array(
-                'method_get' => $sMethodGet,
-            	'method_send' => $sMethodSend,
-            );
+            $aSendUsing[$sDeliveryType] = $sMethodGet;
         }
 
         if(empty($aSendUsing) || !is_array($aSendUsing))
             return;
 
         $iOwner = (int)$aEvent['owner_id'];
-        $aRecipients = array();
+        $aRecipients = [];
 
         //--- Get recipients: Subscribers.
         $oConnection = BxDolConnection::getObjectInstance($this->_oModule->_oConfig->getObject('conn_subscriptions'));
@@ -133,51 +160,86 @@ class BxNtfsCronNotify extends BxDolCron
             if($iIdRead >= $iId)
                 continue;
 
+            if(!$this->_bOwnActions && $aEvent['author_id'] == $iRecipient)
+                continue;
+
             if($oPrivacyExt !== false && !$oPrivacyExt->check($aEvent['id'], $iRecipient)) 
                 continue;
 
             if($oPrivacyInt !== false && !$oPrivacyInt->check($aEvent['id'], $iRecipient))
                 continue;
 
-            foreach($aSendUsing as $sDeliveryType => $aDeliveryType)
+            /**
+             * Check if the Recipient can view the notification.
+             */
+            $sEvent = $this->_oModule->_oTemplate->getPost($aEvent, ['perform_privacy_check_for' => $iRecipient, 'show_real_profile' => false]);
+            if(empty($sEvent) || empty($aEvent['content_parsed']))
+                continue;
+
+            foreach($aSendUsing as $sDeliveryType => $sDeliveryTypeGet) {
+                $mixedNotification = false;
+                if(!$this->_bEventsGroupedDb && ($mixedNotification = $this->_oModule->_oTemplate->$sDeliveryTypeGet($iRecipient, $aEvent)) === false)
+                    continue;
+
                 foreach($aSettingTypes as $sSettingType) {
-                    $aSetting = $this->_oModule->_oDb->getSetting(array('by' => 'tsu_allowed', 'handler_id' => $aHandler['id'], 'delivery' => $sDeliveryType, 'type' => $sSettingType, 'user_id' => $iRecipient));
+                    $aSetting = $this->_oModule->_oDb->getSetting(['by' => 'tsu_allowed', 'handler_id' => $aHandler['id'], 'delivery' => $sDeliveryType, 'type' => $sSettingType, 'user_id' => $iRecipient]);
                     if(empty($aSetting) || !is_array($aSetting))
                         continue;
 
                     if((int)$aSetting['active_adm'] == 0 || (int)$aSetting['active_pnl'] == 0)
                         continue;
 
-                    $mixedNotification = $this->_oModule->_oTemplate->{$aDeliveryType['method_get']}($iRecipient, $aEvent);
-                    if($mixedNotification === false)
-                        continue;
-            
-                    /**
-                     * 'break' is essential in the next two conditions to avoid 
-                     * duplicate sending to the same recipient.
-                     */
-                    if($this->_bDeliveryTimeout && $this->_oModule->_oDb->queueAdd(array(
-                        'profile_id' => $iRecipient, 
-                        'event_id' => $aEvent['id'], 
-                        'delivery' => $sDeliveryType,
-                        'content' => serialize($mixedNotification),
-                        'date' => time()
-                    )) !== false)
+                    if($this->_bEventsGroupedDb) {
+                        $this->_oModule->_oDb->aggregatorAdd([
+                            'profile_id' => $iRecipient, 
+                            'event_id' => $aEvent['id'], 
+                            'delivery' => $sDeliveryType
+                        ]);
                         break;
+                    }
 
-                    if($this->_oModule->{$aDeliveryType['method_send']}($iRecipient, $mixedNotification) !== false)
+                    /**
+                     * (break) is essential to avoid duplicate sending to the same recipient.
+                     */
+                    if($this->_sendNotification($iRecipient, $aEvent['id'], $sDeliveryType, $mixedNotification) !== false)
                         break;
                 }
+            }
         }
     }
 
     protected function _addRecipient($iUser, $sSettingType, &$aRecipients)
     {
         if(!isset($aRecipients[$iUser]))
-            $aRecipients[$iUser] = array();
+            $aRecipients[$iUser] = [];
 
         $aRecipients[$iUser][] = $sSettingType;
     }
+
+    private function _sendNotification($iRecipient, $iEventId, $sDeliveryType, $aNotification)
+    {
+        /**
+         * 'return true' (break) is essential to avoid duplicate sending to the same recipient.
+         * Don't send directly if message was successfully queued.
+         */
+        if($this->_bDeliveryTimeout && $this->_oModule->_oDb->queueAdd([
+            'profile_id' => $iRecipient, 
+            'event_id' => $iEventId, 
+            'delivery' => $sDeliveryType,
+            'content' => serialize($aNotification),
+            'date' => time()
+        ]) !== false)
+            return true;
+
+        /**
+         * 'return true' (break) is essential to avoid duplicate sending to the same recipient.
+         * If a message was sent as 'personal', don't send it by subscription ('follow_member', 'follow_context').
+         */
+        if($this->_oModule->{'sendNotification' . bx_gen_method_name($sDeliveryType)}($iRecipient, $aNotification) !== false)
+            return true;
+
+        return false;
+    }
 }
 
 /** @} */

+ 39 - 0
modules/boonex/notifications/classes/BxNtfsDb.php

@@ -12,6 +12,7 @@
 class BxNtfsDb extends BxBaseModNotificationsDb
 {
     protected $_sTableEvt2Usr;
+    protected $_sTableAggregator;
     protected $_sTableQueue;
     protected $_sTableRead;
     protected $_sTableEtemplates;
@@ -30,6 +31,7 @@ class BxNtfsDb extends BxBaseModNotificationsDb
         );
 
         $this->_sTableEvt2Usr = $this->_sPrefix . 'events2users';
+        $this->_sTableAggregator = $this->_sPrefix . 'aggregator';
         $this->_sTableQueue = $this->_sPrefix . 'queue';
         $this->_sTableRead = $this->_sPrefix . 'read';
         $this->_sTableEtemplates = $this->_sPrefix . 'etemplates';
@@ -510,6 +512,43 @@ class BxNtfsDb extends BxBaseModNotificationsDb
         return $aEvents;
     }
 
+    public function aggregatorGetProfiles()
+    {
+        return $this->getColumn("SELECT DISTINCT `profile_id` FROM `" . $this->_sTableAggregator . "`");
+    }
+
+    public function aggregatorGetEvents($iProfileId, $sDeliveryType)
+    {
+        $sQuery = "SELECT 
+                `te`.*, GROUP_CONCAT(`te`.`id` ORDER BY `th`.`priority` DESC SEPARATOR ',') AS `grouped_by_mac` 
+            FROM `" . $this->_sTableAggregator . "` AS `ta` 
+            INNER JOIN `" . $this->_sTable . "` AS `te` ON `ta`.`event_id`=`te`.`id` 
+            INNER JOIN `" . $this->_sTableHandlers . "` AS `th` ON `te`.`type`=`th`.`alert_unit` AND `te`.`action`=`th`.`alert_action`
+            WHERE 
+                `ta`.`profile_id`=:profile_id AND `ta`.`delivery`=:delivery 
+            GROUP BY `te`.`source_mac`";
+
+        return $this->getAll($sQuery, [
+            'profile_id' => $iProfileId,
+            'delivery' => $sDeliveryType
+        ]);
+    }
+
+    public function aggregatorAdd($aSet)
+    {
+        if(empty($aSet))
+            return false;
+
+        return $this->query("INSERT IGNORE INTO `" . $this->_sTableAggregator . "` SET " . $this->arrayToSQL($aSet)) !== false;
+    }
+
+    public function aggregatorDelete($iProfileId, $aEventsIds)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_sTableAggregator . "` WHERE `profile_id`=:profile_id AND `event_id` IN (" . $this->implode_escape($aEventsIds) . ")", [
+            'profile_id' => $iProfileId,
+        ]) > 0;
+    }
+
     public function queueGet($aParams)
     {
     	$aMethod = array('name' => 'getAll', 'params' => array(0 => 'query'));

+ 7 - 15
modules/boonex/notifications/classes/BxNtfsTemplate.php

@@ -278,7 +278,7 @@ class BxNtfsTemplate extends BxBaseModNotificationsTemplate
                     $aSubEvent[$sK]['author_name_wrapped'] = _t('_bx_ntfs_txt_and');
                 }
 
-                $this->getPost($aSubEvent);
+                $this->getPost($aSubEvent, $aBrowseParams);
 
                 if(($sCp = $aSubEvent['content_parsed'] ?? false))
                     $sContentParsed .= ' ' . $sCp;
@@ -385,13 +385,9 @@ class BxNtfsTemplate extends BxBaseModNotificationsTemplate
 
     public function getNotificationEmail($iRecipient, &$aEvent)
     {
-        $sEvent = $this->getPost($aEvent, ['perform_privacy_check_for' => $iRecipient, 'show_real_profile' => false]);
-        if(empty($sEvent) || empty($aEvent['content_parsed']))
-            return false;
-
         $aContent = &$aEvent['content'];
 
-        $sIconUrl = !empty($aContent['owner_icon']) ? $aContent['owner_icon'] : $this->getIconUrl('std-icon.svg');
+        $sIconUrl = ($_sIconUrl = $aContent['author_icon'] ?? false) || ($_sIconUrl = $aContent['owner_icon'] ?? false) ? $_sIconUrl : $this->getIconUrl('std-icon.svg');
         $sContentUrl = bx_absolute_url($this->_getContentLink($aEvent, getParam('sys_api_url_root_email') != ''));
         $sContent = is_array($aEvent['content_parsed']) && isset($aEvent['content_parsed']['email']) ? $aEvent['content_parsed']['email'] : $aEvent['content_parsed'];
         $sSummary = (($sK = 'subentry_summary') && ($sV = $aContent[$sK] ?? false)) || (($sK = 'entry_summary') && ($sV = $aContent[$sK] ?? false)) ? $sV : '';
@@ -448,10 +444,6 @@ class BxNtfsTemplate extends BxBaseModNotificationsTemplate
 
     public function getNotificationPush($iRecipient, &$aEvent)
     {
-        $sEvent = $this->getPost($aEvent, array('perform_privacy_check_for' => $iRecipient, 'show_real_profile' => false));
-        if(empty($sEvent) || empty($aEvent['content_parsed']))
-            return false;
-
         $sMessage = is_array($aEvent['content_parsed']) && isset($aEvent['content_parsed']['push']) ? $aEvent['content_parsed']['push'] : $aEvent['content_parsed'];
         $sMessage = preg_replace('/<\/?[a-zA-Z0-9=\'":;\(\)\s_-]+>/i', ($sChar = getParam("bx_ntfs_option_tag_to_char_push")) !== false ? $sChar : '"', $sMessage);
         if($sMessage)
@@ -461,14 +453,14 @@ class BxNtfsTemplate extends BxBaseModNotificationsTemplate
             return false;
 
         $aContent = &$aEvent['content'];
-        return array(
-            'content' => array(
+        return [
+            'content' => [
                 'url' => bx_absolute_url($this->_getContentLink($aEvent, getParam('sys_api_url_root_push') != '')),
                 'message' => $sMessage,
                 'icon' => !empty($aContent['owner_icon']) ? $aContent['owner_icon'] : ''
-            ),
-            'settings' => !empty($aContent['settings']['push']) ? $aContent['settings']['push'] : array()
-        );
+            ],
+            'settings' => !empty($aContent['settings']['push']) ? $aContent['settings']['push'] : []
+        ];
     }
 
     public function getEmpty($bVisible = true)

+ 10 - 2
modules/boonex/notifications/install/sql/install.sql

@@ -13,8 +13,8 @@ CREATE TABLE IF NOT EXISTS `bx_notifications_events` (
   `object_privacy_view` varchar(32) NOT NULL default '3',
   `subobject_id` int(11) NOT NULL default '0',
   `content` text NOT NULL,
-  `source` varchar(32) NOT NULL default '',
-  `source_mac` varchar(32) NOT NULL default '',
+  `source` varchar(48) NOT NULL default '',
+  `source_mac` varchar(64) NOT NULL default '',
   `allow_view_event_to` varchar(32) NOT NULL default '3',
   `date` int(11) NOT NULL default '0',
   `processed` tinyint(4) NOT NULL default '0',
@@ -212,6 +212,14 @@ INSERT INTO `bx_notifications_settings`(`group`, `handler_id`, `delivery`, `type
 ('action_required', @iHandlerId, 'email', 'personal', '_bx_ntfs_alert_action_reported_content_personal', 8),
 ('action_required', @iHandlerId, 'push', 'personal', '_bx_ntfs_alert_action_reported_content_personal', 8);
 
+CREATE TABLE IF NOT EXISTS `bx_notifications_aggregator` (
+  `id` int(11) NOT NULL auto_increment,
+  `profile_id` int(11) NOT NULL DEFAULT '0',
+  `event_id` int(11) NOT NULL DEFAULT '0',
+  `delivery` varchar(64) NOT NULL default '',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `item` (`profile_id`, `event_id`, `delivery`(16))
+);
 
 CREATE TABLE IF NOT EXISTS `bx_notifications_queue` (
   `id` int(11) NOT NULL auto_increment,

+ 3 - 0
modules/boonex/ocean/data/template/system/informer.html

@@ -3,6 +3,9 @@
         <bx_repeat:messages>
             <div id="__id__" class="bx-informer-msg __class__ bx-def-padding bx-def-round-corners bx-def-margin-top">
                 __msg__
+                <bx_if:action>
+                    <a class="bx-informer-action bx-btn bx-btn-small bx-def-margin-sec-left" href="__action_url__">__action_title__</a>
+                </bx_if:action>
             </div>
         </bx_repeat:messages>
     </div>

+ 2 - 1
modules/boonex/russian/data/langs/system/ru.xml

@@ -4198,7 +4198,8 @@
     <string name="_sys_txt_account_suspended"><![CDATA[Ваш аккаунт заблокирован.]]></string>
     <string name="_sys_txt_account_unconfirmed_email"><![CDATA[Ваш email не подтверждён. Пожалуйста, проверьте свой эл. ящик ({1}) на наличие письма подтверждения и перейдите по ссылке в нём или используйте код подтверждения (6 цифр), чтобы <a href="{2}">подтвердить свой адрес эл. почты</a>. <br />Не можете найти письмо? Проверьте папку "спам" или <a href="{0}">снова запросите письмо верификации</a>.]]></string>
     <string name="_sys_txt_account_unconfirmed_phone"><![CDATA[Ваш телефон не подтверждён. Пожалуйста, подтвердите его на  <a href="{0}">странице верификации</a>.]]></string>
-    <string name="_sys_txt_account_profile_system"><![CDATA[Пожалуйста, <a href="{0}">создайте профиль</a>, чтобы начать пользоваться сайтом.]]></string>
+    <string name="_sys_txt_account_profile_system"><![CDATA[У вашего аккаунта ещё нет профиля. Создайте его, чтобы начать пользоваться сайтом.]]></string>
+    <string name="_sys_txt_account_profile_system_action"><![CDATA[Создать профиль]]></string>
     <string name="_sys_txt_account_need_to_change_password"><![CDATA[Ваш пароль устарел. Пожалуйста <a href="{0}">измените Ваш пароль</a>.]]></string>
     <string name="_sys_txt_account_cannot_delete"><![CDATA[Невозможно удалить единственную учетную запись оператора.]]></string>
     <string name="_sys_txt_account_deletion_scheduled"><![CDATA[Удаление аккаунта запланировано и будет выполнено через несколько минут.]]></string>

+ 9 - 0
modules/boonex/tasks/classes/BxTasksCmts.php

@@ -119,6 +119,15 @@ class BxTasksCmts extends BxTemplCmts
         return $aTmplVarsText;
     }
 
+    protected function _callAlert($sUnit, $sAction, $iObjectId, $iSender = false, $aExtras = [])
+    {
+        echo 'Here' . $iSender . ' - ' . $this->_iAuthorAuto;
+        if($iSender && $iSender == $this->_iAuthorAuto)
+            return;
+
+        return bx_alert($sUnit, $sAction, $iObjectId, $iSender, $aExtras);
+    }
+
     protected function _prepareAlertParams($aCmt)
     {
         $aResult = parent::_prepareAlertParams($aCmt);

+ 47 - 16
modules/boonex/tasks/classes/BxTasksModule.php

@@ -1398,6 +1398,13 @@ class BxTasksModule extends BxBaseModTextModule implements iBxDolCalendarService
         if(!$aContentInfo)
             return;
 
+        $sModule = $this->getName();
+        $sAction = 'expired';
+
+        $iPerformer = getParam('sys_profile_bot');
+        $sSource = $sModule . '_' . $sAction . '_' . $iContentId;
+        $sSourceMac = $sModule . '_' . $sAction . '_' . $iPerformer . '_' . time();
+
         $oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTION']);
         if($oConnection) {
             $aProfileIds = $oConnection->getConnectedContent($iContentId);
@@ -1415,10 +1422,13 @@ class BxTasksModule extends BxBaseModTextModule implements iBxDolCalendarService
                      *      - `privacy_view` - [string] privacy view value
                      * @hook @ref hook-bx_tasks-expired
                      */
-                    bx_alert($this->getName(), 'expired', $iContentId, getParam('sys_profile_bot'), array(
+                    bx_alert($sModule, $sAction, $iContentId, $iPerformer, [
                         'object_author_id' => $iProfileId,
-                        'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]
-                    ));
+                        'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']],
+
+                        'source' => $sSource,
+                        'source_mac' => $sSourceMac,
+                    ]);
                 }
         }
     }
@@ -1510,6 +1520,11 @@ class BxTasksModule extends BxBaseModTextModule implements iBxDolCalendarService
 
         $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
         $iContentAuthor = (int)$aContentInfo[$CNF['FIELD_AUTHOR']];
+
+        $iPerformer = bx_get_logged_profile_id();
+        $sSource = $sModule . '_' . $sAction . '_' . $iContentId;
+        $sSourceMac = $sModule . '_' . $sAction . '_' . $iPerformer . '_' . time();
+
         /**
          * @hooks
          * @hookdef hook-bx_tasks-completed 'bx_tasks', 'completed' - hook on task unassigned to profile
@@ -1522,10 +1537,13 @@ class BxTasksModule extends BxBaseModTextModule implements iBxDolCalendarService
          *      - `privacy_view` - [string] privacy view value
          * @hook @ref hook-bx_tasks-completed
          */
-        bx_alert($sModule, $sAction, $iContentId, false, array(
+        bx_alert($sModule, $sAction, $iContentId, $iPerformer, [
             'object_author_id' => $iContentAuthor,
-            'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]
-        ));
+            'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']],
+
+            'source' => $sSource,
+            'source_mac' => $sSourceMac,
+        ]);
 
         if(($oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTION'])) !== false) {
             $aProfileIds = $oConnection->getConnectedContent($iContentId);
@@ -1534,10 +1552,13 @@ class BxTasksModule extends BxBaseModTextModule implements iBxDolCalendarService
                     if($iProfileId == $iContentAuthor)
                         continue;
 
-                    bx_alert($sModule, $sAction, $iContentId, false, array(
+                    bx_alert($sModule, $sAction, $iContentId, $iPerformer, [
                         'object_author_id' => $iProfileId,
-                        'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]
-                    ));
+                        'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']],
+
+                        'source' => $sSource,
+                        'source_mac' => $sSourceMac,
+                    ]);
                 }
         }
 
@@ -1693,6 +1714,9 @@ class BxTasksModule extends BxBaseModTextModule implements iBxDolCalendarService
     public function setAssignments($iContentId, $aMembers)
     {
         $CNF = &$this->_oConfig->CNF;
+
+        $sModule = $this->getName();
+        $iPerformer = bx_get_logged_profile_id();
         $oConn = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTION']);
 
         if($this->_bIsApi && is_string($aMembers))
@@ -1705,8 +1729,9 @@ class BxTasksModule extends BxBaseModTextModule implements iBxDolCalendarService
         if (is_array($aMembers)){
             $aMembersToAdd = array_diff($aMembers, $aMembersCurrent);
             $aMembersToRemove = array_diff($aMembersCurrent, $aMembers);
-        }    
+        }
 
+        $iNow = time();
         $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
 
         foreach($aMembersToAdd as $iProfileId){
@@ -1724,10 +1749,13 @@ class BxTasksModule extends BxBaseModTextModule implements iBxDolCalendarService
              *      - `privacy_view` - [string] privacy view value
              * @hook @ref hook-bx_tasks-assigned
              */
-            bx_alert($this->_aModule['name'], 'assigned', $iContentId, false, array(
+            bx_alert($sModule, 'assigned', $iContentId, $iPerformer, [
                 'object_author_id' => $iProfileId,
-                'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]
-            ));
+                'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']],
+
+                'source' => $sModule . '_assigned_' . $iContentId,
+                'source_mac' => $sModule . '_assigned_' . $iPerformer . '_' . $iNow,
+            ]);
         }
 
         foreach($aMembersToRemove as $iProfileId){
@@ -1745,10 +1773,13 @@ class BxTasksModule extends BxBaseModTextModule implements iBxDolCalendarService
              *      - `privacy_view` - [string] privacy view value
              * @hook @ref hook-bx_tasks-unassigned
              */
-            bx_alert($this->_aModule['name'], 'unassigned', $iContentId, false, array(
+            bx_alert($sModule, 'unassigned', $iContentId, $iPerformer, [
                 'object_author_id' => $iProfileId,
-                'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]
-            ));
+                'privacy_view' => $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']],
+
+                'source' => $sModule . '_unassigned_' . $iContentId,
+                'source_mac' => $sModule . '_unassigned_' . $iPerformer . '_' . $iNow,
+            ]);
         }
     }
 

+ 6 - 3
package.json

@@ -34,7 +34,7 @@
     "grapesjs-style-bg": "^2.0.2",
     "headroom.js": "^0.12.0",
     "highlight.js": "github:highlightjs/highlight.js",
-    "hls.js": "^1.7.1",
+    "hls.js": "^1.7.2",
     "htmx-ext-head-support": "^2.0.5",
     "htmx-ext-preload": "^2.1.2",
     "htmx.org": "^2.0.10",
@@ -78,7 +78,7 @@
     "spin.js": "^4.1.2",
     "stackedit-js": "^1.0.7",
     "tablesorter": "^2.32.0",
-    "theia-sticky-sidebar": "^2.0.0",
+    "theia-sticky-sidebar": "^2.0.1",
     "urijs": "^1.19.11"
   },
   "scripts": {
@@ -117,6 +117,7 @@
     "build:skrollr": "mkdir -p plugins_public/skrollr; cd plugins_public/skrollr; cp -r ../../node_modules/skrollr/dist/* .",
     "build:spin.js": "mkdir -p plugins_public/spin.js; cd plugins_public/spin.js; cp -r ../../node_modules/spin.js/spin.* .; grep -v 'export { Spinner };' spin.js > tmp && mv tmp spin.js",
     "build:stackedit.js": "mkdir -p plugins_public/stackedit.js; cd plugins_public/stackedit.js; cp -r ../../node_modules/stackedit-js/docs/lib/* .",
+    "build:lucide": "mkdir -p plugins_public/lucide/icons; cp node_modules/lucide-static/icons/*.svg plugins_public/lucide/icons; cp node_modules/lucide/dist/umd/lucide.min.js plugins_public/lucide",
     "build:tailwind-max": "NODE_ENV=production tailwindcss -i ./template/css/tailwind.css -c ./tailwind.config.full.js -o ./plugins_public/tailwind/css/tailwind.max.css --minify",
     "build:tailwind-min": "NODE_ENV=production tailwindcss -i ./template/css/tailwind.css -c ./tailwind.config.js -o ./plugins_public/tailwind/css/tailwind.min.css --minify",
     "build:theia-sticky-sidebar": "mkdir -p plugins_public/theia-sticky-sidebar; cd plugins_public/theia-sticky-sidebar; cp -r ../../node_modules/theia-sticky-sidebar/dist/* .",
@@ -163,8 +164,10 @@
     "@tanstack/ai-utils": "^0.4.0",
     "@tanstack/devtools-event-client": "^0.5.0",
     "@types/react": "^19.2.18",
-    "@types/react-dom": "^19.2.5",
+    "@types/react-dom": "^19.2.7",
     "bower": "^1.8.14",
+    "lucide": "1.41.0",
+    "lucide-static": "1.41.0",
     "npm-run-all": "^4.1.5",
     "partial-json": "^0.1.7",
     "react": "^19.2.8",

+ 2 - 0
tailwind.config.js

@@ -79,6 +79,8 @@ module.exports = {
 
     'bx-informer-msg-info', 'bx-informer-msg-alert', 'bx-informer-msg-error',
 
+    'bx-btn-mini', 'bx-btn-small', 'bx-btn-large', 'bx-btn-xlarge',
+
     'sys-action-counter', 'sys-ac-only', 'sys-ac-link',
 
     'bx-vote-bls-submenu-cnt', 

+ 3 - 4
template/css/common.css

@@ -10,9 +10,8 @@ a {
     text-decoration:none;
     color:#3e8685; /* 26.13.13: prev val #387877; */
 }
-/* No outline after a mouse click, but keyboard focus stays visible. */
 a:focus:not(:focus-visible) {
-    outline:none;
+    outline: none;
 }
 
 a:hover {
@@ -770,8 +769,8 @@ div.bx-popup-chain-item .bx-loading-ajax {
 }
 
 button.bx-btn:focus:not(:focus-visible),
-button.bx-btn:focus .bx-loading-ajax-btn {
-    outline: 0;	
+button.bx-btn:focus:not(:focus-visible) .bx-loading-ajax-btn {
+    outline: 0;
 }
 
 .bx-form-element-wrapper .bx-loading-ajax {

+ 1 - 15
template/css/informer.css

@@ -1,17 +1,3 @@
-
 .bx-informer-msg:first-child {
-    margin-top:0;
-}
-.bx-informer-icon {
-    display: none;
-    vertical-align: top;
-}
-.bx-informer-msg-info .bx-informer-icon-info {
-    display: inline-block;
-}
-.bx-informer-msg-alert .bx-informer-icon-alert {
-    display: inline-block;
-}
-.bx-informer-msg-error .bx-informer-icon-error {
-    display: inline-block;
+    margin-top: 0;
 }

+ 51 - 12
template/css/tailwind.css

@@ -702,7 +702,8 @@
         @apply hover:-translate-y-px;
     }
     .bx-btn-focus {
-        @apply focus:border-blue-500 dark:focus:border-blue-500 focus:border-opacity-70 focus:ring-2 focus:ring-blue-500 focus:ring-opacity-20 focus:outline-none;
+        @apply focus:outline-none;
+        @apply focus-visible:border-blue-500 dark:focus-visible:border-blue-500 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900;
     }
     
     .bx-btn-active {
@@ -712,10 +713,10 @@
         @apply active:translate-y-px;
     }
     .bx-btn {
-        @apply relative inline-flex items-center px-4 py-2;
+        @apply relative inline-flex items-center min-h-11 px-4 py-2 touch-manipulation;
         @apply bx-btn-shadow;
         @apply bx-btn-bg;
-        @apply bx-btn-border;
+        @apply bx-btn-border rounded-[10px];
         @apply bx-btn-text;
         @apply transform-gpu transition duration-150;
         @apply cursor-pointer;
@@ -758,8 +759,8 @@
     }
 
     .bx-btn.bx-btn-primary {
-        @apply bg-blue-600 hover:bg-blue-500 active:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600 dark:active:bg-blue-800;
-        @apply border border-blue-600 hover:border-blue-500 active:border-blue-800 dark:border-blue-600 dark:hover:border-blue-500 dark:active:border-blue-700;
+        @apply bg-blue-600 hover:bg-blue-700 active:bg-blue-800 dark:bg-blue-700 dark:hover:bg-blue-600 dark:active:bg-blue-800;
+        @apply border border-blue-600 hover:border-blue-700 active:border-blue-800 dark:border-blue-600 dark:hover:border-blue-500 dark:active:border-blue-700;
         @apply text-white hover:text-white active:text-white dark:text-white dark:hover:text-white dark:active:text-white;
     }
 
@@ -777,10 +778,24 @@
         @apply bg-gray-50 dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-400 hover:text-gray-400 dark:hover:text-gray-400 focus:ring-0 focus:ring-offset-0 shadow-none cursor-default !important;
     }
 
+    /* Button sizes (height / corner radius): mini 28px/6px with the hit area extended to 44px, small 36px/8px, default 44px/10px, large 52px/12px, xlarge 60px/14px. */
+    .bx-btn.bx-btn-mini {
+        @apply min-h-7 px-2.5 py-1 text-xs rounded-md;
+        @apply after:content-[''] after:absolute after:-inset-y-2 after:-inset-x-1;
+    }
     .bx-btn.bx-btn-small {
-        @apply py-1 text-xs;
+        @apply min-h-9 px-3 py-1 text-sm rounded-lg;
+    }
+    .bx-btn.bx-btn-large {
+        @apply min-h-[3.25rem] px-5 text-base rounded-xl;
+    }
+    .bx-btn.bx-btn-xlarge {
+        @apply min-h-[3.75rem] px-6 text-lg rounded-[14px];
     }
 
+    .bx-btn.bx-btn-mini i, 
+    .bx-btn.bx-btn-mini img,
+    .bx-btn.bx-btn-mini svg,
     .bx-btn.bx-btn-small i, 
     .bx-btn.bx-btn-small img,
     .bx-btn.bx-btn-small svg {
@@ -1242,17 +1257,41 @@
 
 
     /* Common elements: Informers */
-    .bx-informer-msg a {
-        @apply text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-600;
+    .bx-informer-msg {
+        @apply flex items-center gap-3 py-3 px-4 rounded-xl ring-1 ring-inset text-sm leading-5;
+    }
+    .bx-informer-indicator {
+        @apply flex-none flex items-center justify-center w-9 h-9 rounded-full;
+    }
+    .bx-informer-indicator .sys-icon {
+        @apply w-5 h-5 leading-5 text-xl text-center;
+    }
+    .bx-informer-content {
+        @apply flex-1 min-w-0 font-medium;
+    }
+    .bx-informer-content a:not(.bx-btn) {
+        @apply font-semibold underline underline-offset-2 hover:opacity-80;
+    }
+    .bx-informer-action {
+        @apply flex-none whitespace-nowrap;
     }
     .bx-informer-msg-info {
-        @apply bg-blue-50 text-blue-700;
+        @apply bg-blue-50 text-blue-800 ring-blue-200 dark:bg-blue-500/10 dark:text-blue-200 dark:ring-blue-500/25;
+    }
+    .bx-informer-msg-info .bx-informer-indicator {
+        @apply bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300;
     }
     .bx-informer-msg-alert {
-        @apply bg-yellow-50 text-yellow-700;
+        @apply bg-yellow-50 text-yellow-800 ring-yellow-200 dark:bg-yellow-500/10 dark:text-yellow-200 dark:ring-yellow-500/25;
+    }
+    .bx-informer-msg-alert .bx-informer-indicator {
+        @apply bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-300;
     }
     .bx-informer-msg-error {
-        @apply bg-red-50 text-red-700;
+        @apply bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/25;
+    }
+    .bx-informer-msg-error .bx-informer-indicator {
+        @apply bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300;
     }
 
 
@@ -1581,7 +1620,7 @@
     
     .bx-std .bx-btn {
         @apply inline-flex items-center;
-        @apply h-10;
+        @apply min-h-11;
     }
 
     .bx-std .bx-btn:not(.bx-btn-primary) {

+ 10 - 14
template/informer.html

@@ -1,20 +1,16 @@
 <div class="bx-def-page-width mx-auto px-4 pt-4 box-border">
-    <div id="bx-informer">
+    <div id="bx-informer" class="space-y-3">
         <bx_repeat:messages>
-            <div id="__id__" class="bx-informer-msg __class__ flex items-center mt-4 first:mt-0 p-4 rounded-md">
-                <div class="flex-none w-5 h-5">
-                    <svg class="bx-informer-icon bx-informer-icon-info text-blue-400 h-5 w-5" x-description="Heroicon name: solid/information-circle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
-                        <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
-                    </svg>
-                    <svg class="bx-informer-icon bx-informer-icon-alert h-5 w-5 text-yellow-400" x-description="Heroicon name: solid/exclamation" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
-                        <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
-                    </svg>
-                    <svg class="bx-informer-icon bx-informer-icon-error h-5 w-5 text-red-400" x-description="Heroicon name: solid/x-circle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
-                        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
-                    </svg>
+            <div id="__id__" class="bx-informer-msg __class__">
+                <div class="bx-informer-indicator" aria-hidden="true">
+                    <bx_if:icon_html>__icon_html__</bx_if:icon_html>
+                    <bx_if:icon_font><i class="sys-icon __icon__"></i></bx_if:icon_font>
                 </div>
-                <div class="ml-3 text-sm">__msg__</div>
+                <div class="bx-informer-content">__msg__</div>
+                <bx_if:action>
+                    <a class="bx-informer-action bx-btn bx-btn-small bx-btn-primary" href="__action_url__">__action_title__</a>
+                </bx_if:action>
             </div>
         </bx_repeat:messages>
     </div>
-</div>
+</div>

+ 11 - 0
template/scripts/BxBaseIconset.php

@@ -40,6 +40,17 @@ class BxBaseIconset extends BxDolIconset
         return $sIcon;
     }
 
+    /**
+     * Get icon as ready to use HTML code (e.g. inline SVG) which doesn't depend on the iconset's CSS/JS being loaded on the page.
+     * @param $sIcon - icon name
+     * @param $aAttrs - additional HTML attributes, e.g. `class`
+     * @return string|false - HTML code or false if the iconset doesn't support it and a font icon (`<i class="sys-icon ...">`) should be used instead.
+     */
+    public function getIconHtml($sIcon, $aAttrs = [])
+    {
+        return false;
+    }
+
     public function getCode()
     {
         return false;

+ 64 - 15
template/scripts/BxBaseIconsetLucide.php

@@ -13,7 +13,10 @@
  */
 class BxBaseIconsetLucide extends BxBaseIconset
 {
+    const VERSION = '1.41.0'; ///< Lucide version used when the local copy (see `build:lucide` in package.json) isn't available.
+
     protected $_aMap;
+    protected static $_aCacheSvg = [];
 
     public function __construct ($aObject, $oTemplate)
     {
@@ -41,8 +44,8 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'cart-plus' => 'shopping-cart',
             'cc-stripe' => 'credit-card',
             'certificate' => 'award',
-            'chart-pie' => 'pie-chart',
-            'check-circle' => 'check-circle',
+            'chart-pie' => 'chart-pie',
+            'check-circle' => 'circle-check',
             'check-double' => 'check-check',
             'clipboard-check' => 'clipboard-check',
             'cog' => 'settings',
@@ -52,14 +55,14 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'comments' => 'messages-square',
             'desktop' => 'monitor',
             'donate' => 'hand-coins',
-            'ellipsis-h' => 'more-horizontal',
-            'ellipsis-v' => 'more-vertical',
+            'ellipsis-h' => 'ellipsis',
+            'ellipsis-v' => 'ellipsis-vertical',
             'envelope' => 'mail',
             'envelope-open' => 'mail-open',
             'envelope-open-text' => 'mail-open',
             'exchange-alt' => 'repeat',
-            'exclamation-circle' => 'alert-circle',
-            'exclamation-triangle' => 'alert-triangle',
+            'exclamation-circle' => 'circle-alert',
+            'exclamation-triangle' => 'triangle-alert',
             'fa-book' => 'book-text',
             'fa-bookmark' => 'bookmark',
             'fa-certificate' => 'badge',
@@ -79,15 +82,15 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'google' => 'log-in', //TODO: Brand icon. Update later.
             'hand-holding-usd' => 'hand-coins',
             'hashtag' => 'hash',
-            'helpcircle' => 'help-circle',
-            'house' => 'home',
+            'helpcircle' => 'circle-question-mark',
+            'house' => 'house',
             'industry' => 'factory',
             'info-circle' => 'info',
             'keyround' => 'key-round',
             'language' => 'languages',
             'list-alt' => 'list',
             'linkedin-in' => 'log-in', //TODO: Brand icon. Update later.
-            'lockopen' => 'unlock',
+            'lockopen' => 'lock-open',
             'mail-bulk' => 'mails',
             'map-marker' => 'map-pin',
             'map-marker-alt' => 'map-pin',
@@ -96,7 +99,7 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'pencil-alt' => 'pencil-line',
             'pencil-ruler' => 'ruler',
             'photo-video' => 'image-play',
-            'plus-circle' => 'plus-circle',
+            'plus-circle' => 'circle-plus',
             'qrcode' => 'qr-code',
             'question' => 'circle-question-mark',
             'question-circle' => 'circle-question-mark',
@@ -114,15 +117,15 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'sync' => 'refresh-ccw',
             'sync-alt' => 'refresh-ccw',
             'tachometer-alt' => 'gauge',
-            'tasks' => 'check-square',
+            'tasks' => 'square-check',
             'th-large' => 'grid-2x2',
             'thumbtack' => 'pin',
             'times' => 'x',
-            'times-circle' => 'x-circle',
+            'times-circle' => 'circle-x',
             'toolbox' => 'tool-case',
             'trash2' => 'trash-2',
             'twitter' => 'log-in', //TODO: Brand icon. Update later.
-            'unlock-alt' => 'unlock',
+            'unlock-alt' => 'lock-open',
             'user' => 'user-round',
             'user-friends' => 'users',
             'user-plus' => 'user-round-plus',
@@ -142,16 +145,62 @@ class BxBaseIconsetLucide extends BxBaseIconset
 
     public function getPreloaderJs()
     {
-        return 'https://unpkg.com/lucide@latest';
+        if(file_exists(BX_DIRECTORY_PATH_PLUGINS_PUBLIC . 'lucide/lucide.min.js'))
+            return '{dir_plugins_public}lucide/|lucide.min.js';
+
+        return 'https://unpkg.com/lucide@' . self::VERSION;
     }
 
     public function getIcon($sIcon)
+    {
+        return bx_gen_method_name($this->_getIconName($sIcon), ['_', '-']);
+    }
+
+    public function getIconHtml($sIcon, $aAttrs = [])
+    {
+        $sName = $this->_getIconName($sIcon);
+        if(!preg_match('/^[a-z0-9-]+$/', $sName))
+            return false;
+
+        if(!isset(self::$_aCacheSvg[$sName])) {
+            $sPath = BX_DIRECTORY_PATH_PLUGINS_PUBLIC . 'lucide/icons/' . $sName . '.svg';
+
+            $sSvg = file_exists($sPath) ? file_get_contents($sPath) : false;
+            if($sSvg !== false) {
+                $sSvg = preg_replace('/<!--.*?-->/s', '', $sSvg);
+                $sSvg = trim(preg_replace('/\s+/', ' ', $sSvg));
+                $sSvg = str_replace(' />', '/>', $sSvg);
+            }
+
+            self::$_aCacheSvg[$sName] = $sSvg;
+        }
+
+        $sSvg = self::$_aCacheSvg[$sName];
+        if($sSvg === false)
+            return false;
+
+        $sClass = 'sys-icon';
+        if(!empty($aAttrs['class']))
+            $sClass .= ' ' . $aAttrs['class'];
+        unset($aAttrs['class']);
+
+        $sAttrs = '';
+        foreach($aAttrs as $sKey => $sValue)
+            $sAttrs .= ' ' . $sKey . '="' . bx_html_attribute($sValue) . '"';
+
+        return preg_replace('/^<svg class="/', '<svg' . $sAttrs . ' class="' . bx_html_attribute($sClass) . ' ', $sSvg, 1);
+    }
+
+    /**
+     * Get Lucide icon name (kebab-case) by Font Awesome or Lucide icon name.
+     */
+    protected function _getIconName($sIcon)
     {
         $sIcon = trim(preg_replace('/(sys-icon|far|col-\w+)/i', '', $sIcon));
         if(isset($this->_aMap[$sIcon]))
             $sIcon = $this->_aMap[$sIcon];
 
-        return bx_gen_method_name($sIcon, ['_', '-']);
+        return $sIcon;
     }
 
     public function getCode()

+ 55 - 12
template/scripts/BxBaseInformer.php

@@ -17,11 +17,23 @@ class BxBaseInformer extends BxDolInformer
 
     protected $_oTemplate;
 
-    protected $_aMapType2Class = array(
+    protected $_aMapType2Class = [
         BX_INFORMER_ALERT => 'bx-informer-msg-alert',
         BX_INFORMER_INFO => 'bx-informer-msg-info',
         BX_INFORMER_ERROR => 'bx-informer-msg-error',
-    );
+    ];
+
+    protected $_aMapType2Icon = [
+        BX_INFORMER_ALERT => 'exclamation-triangle',
+        BX_INFORMER_INFO => 'info-circle',
+        BX_INFORMER_ERROR => 'times-circle',
+    ];
+
+    /**
+     * Iconset used to render indicator icons as inline SVG regardless of the site's default iconset.
+     * When it isn't available (or has no such icon) a font icon of the default iconset is used.
+     */
+    protected $_sIconset = 'sys_lucide';
 
     public function __construct ($oTemplate)
     {
@@ -44,20 +56,51 @@ class BxBaseInformer extends BxDolInformer
         if (!$this->_aMessages)
             return '';
 
-        $aTmplVarsMessages = [];
-        foreach ($this->_aMessages as $sId => $a) {
-            $a['class'] = $this->_aMapType2Class[$a['type']];
+        $oIconset = BxDolIconset::getObjectInstance($this->_sIconset, $this->_oTemplate);
 
-            $aTmplVarsMessages[] = $a;
-        }
-        
-        if (bx_is_api()){
-            return $aTmplVarsMessages;
-        }
+        $aMessages = [];
+        foreach ($this->_aMessages as $a)
+            $aMessages[] = array_merge($a, [
+                'class' => $this->_aMapType2Class[$a['type']],
+                'icon' => !empty($a['icon']) ? $a['icon'] : $this->_aMapType2Icon[$a['type']],
+            ]);
+
+        if (bx_is_api())
+            return array_map(function($a) use($oIconset) {
+                // API clients (NEO) render Lucide icons natively, so send the Lucide name.
+                return array_merge($a, [
+                    'icon' => $oIconset ? $oIconset->getIcon($a['icon']) : $a['icon'],
+                ]);
+            }, $aMessages);
 
         $this->_addJsCss();
+
         return $this->_oTemplate->parseHtmlByName('informer.html', [
-            'bx_repeat:messages' => $aTmplVarsMessages
+            'bx_repeat:messages' => array_map(function($a) use($oIconset) {
+                $sIconHtml = $oIconset ? $oIconset->getIconHtml($a['icon']) : false;
+
+                return array_merge($a, [
+                    'bx_if:icon_html' => [
+                        'condition' => $sIconHtml !== false,
+                        'content' => [
+                            'icon_html' => $sIconHtml,
+                        ],
+                    ],
+                    'bx_if:icon_font' => [
+                        'condition' => $sIconHtml === false,
+                        'content' => [
+                            'icon' => $a['icon'],
+                        ],
+                    ],
+                    'bx_if:action' => [
+                        'condition' => !empty($a['action_url']) && !empty($a['action_title']),
+                        'content' => [
+                            'action_url' => bx_html_attribute($a['action_url']),
+                            'action_title' => $a['action_title'],
+                        ],
+                    ],
+                ]);
+            }, $aMessages),
         ]);
     }
 

+ 11 - 0
template/scripts/BxBaseServices.php

@@ -825,6 +825,17 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         return $aResult;
     }
 
+    public function serviceGetBlockStatic($sBlockName)
+    {
+        $bIsApi = bx_is_api();
+        if(!$bIsApi)
+            return '';
+
+        return [bx_api_get_block('static', [
+            'element' => $sBlockName
+        ])];
+    }
+
     public function serviceGetBlockAuthor($sModule, $iContentId = 0)
     {
         if(!$iContentId && bx_get('id') !== false)

+ 22 - 14
yarn.lock

@@ -823,10 +823,10 @@
   resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
   integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
 
-"@types/react-dom@^19.2.5":
-  version "19.2.5"
-  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.5.tgz#d4ecf8d704cab59901d3fbb0975b6f855306595f"
-  integrity sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==
+"@types/react-dom@^19.2.7":
+  version "19.2.7"
+  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.7.tgz#eba78fc6201e564986ec40d56c86c8434593cace"
+  integrity sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==
 
 "@types/react@^19.2.18":
   version "19.2.18"
@@ -2125,13 +2125,12 @@ headroom.js@^0.12.0:
 
 "highlight.js@github:highlightjs/highlight.js":
   version "11.12.0"
-  uid fc3f06392f189354eed922973635ab9e9268b983
   resolved "https://codeload.github.com/highlightjs/highlight.js/tar.gz/fc3f06392f189354eed922973635ab9e9268b983"
 
-hls.js@^1.7.1:
-  version "1.7.1"
-  resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.7.1.tgz#dcaa09ffb6347cdf06fc41dada2118f0d02ab5fc"
-  integrity sha512-DlzIkeBAS9IIQ432k3BUf3HlwbsR0+trB1i2lDdN2gUkNkrehFurh0/48M5c1/EjlDkdGng1gwZIpwyPxvdZ/g==
+hls.js@^1.7.2:
+  version "1.7.2"
+  resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.7.2.tgz#8d414feeccffb9e91f30059bdf95aa3fe6376f2a"
+  integrity sha512-CW/pPvSOFIRsosbwxrYaE9ERmpTo5fbTqL7wCvuCFlqW1Bmb1K5fXsY7yiH95rmlr4rVuA7UXHbM/cIXQ6AXwg==
 
 hosted-git-info@^10.1.0, hosted-git-info@^10.1.1:
   version "10.1.1"
@@ -2634,7 +2633,6 @@ jstree@^3.3.17:
 
 "jush@https://github.com/vrana/jush":
   version "0.0.0"
-  uid e89a152b995b33b2f308078c3dee7bee506711e3
   resolved "https://github.com/vrana/jush#e89a152b995b33b2f308078c3dee7bee506711e3"
 
 just-diff-apply@^5.2.0:
@@ -2816,6 +2814,16 @@ lru-cache@^11.0.0, lru-cache@^11.1.0, lru-cache@^11.2.1:
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.2.tgz#00e16665c90c620fba14a3c368732a976493f760"
   integrity sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==
 
+lucide-static@1.41.0:
+  version "1.41.0"
+  resolved "https://registry.yarnpkg.com/lucide-static/-/lucide-static-1.41.0.tgz#56c437537404a446883ff611301ef2442835bece"
+  integrity sha512-39fX7SH+Rwis0oUmLLOipOoFSiJll9yi2DyEGDaE7Sp0qQAEhEfMQ2scQNdWKeGVENGv1uXc5ZeZqBWsuhQSFg==
+
+lucide@1.41.0:
+  version "1.41.0"
+  resolved "https://registry.yarnpkg.com/lucide/-/lucide-1.41.0.tgz#e2336f8764b5b1bbfe62e7f621c7aaa81fc1c3fd"
+  integrity sha512-fIXP7JzC1vehdqOrMhwpbcXzX6CvUMuoDf16HY1FmC4sDBeuwrH2A0c6QmLn3wnSvaMchpEyM+JWijyTvOs3kw==
+
 magic-string@^0.30.3:
   version "0.30.21"
   resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
@@ -4720,10 +4728,10 @@ text-table@~0.2.0:
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
   integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
 
-theia-sticky-sidebar@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/theia-sticky-sidebar/-/theia-sticky-sidebar-2.0.0.tgz#ebc66c8e4e9690b5c2d87c392b838c912a8e1fb5"
-  integrity sha512-MMjaEb/1LJPpcTbxN3803ZO5jMEAq99miOD+bNRvRi636y2TJxb5UFS21Pzg2qrCls9quuxcLzDlUSBw2qL6xQ==
+theia-sticky-sidebar@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/theia-sticky-sidebar/-/theia-sticky-sidebar-2.0.1.tgz#0564c828e976be379a33e7b4e96f137ad0cd3124"
+  integrity sha512-4JZ+Z7uQe++Pwyhn9mOyylBDOZFjs6PPtr2vy1oSoWnFJwxNsDylQ82xTTSLEf3M6o+EWJ2/e1TgYWsM+E8C6g==
 
 thenify-all@^1.0.0:
   version "1.6.0"