Skip to content

Commit 7daf1a6

Browse files
committed
Add Silent Push notification to update Unread Notification Count in all situations
humhub/app-internal#74 and humhub/app#52
1 parent 94995ca commit 7daf1a6

10 files changed

Lines changed: 169 additions & 33 deletions

File tree

Events.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
use humhub\modules\fcmPush\components\NotificationTargetProvider;
88
use humhub\modules\fcmPush\helpers\MobileAppHelper;
99
use humhub\modules\fcmPush\helpers\WebAppHelper;
10+
use humhub\modules\fcmPush\jobs\SendSilentUnreadNotificationCountJob;
1011
use humhub\modules\fcmPush\services\ServiceWorkerService;
1112
use humhub\modules\fcmPush\widgets\RegisterDeviceTokenButton;
13+
use humhub\modules\notification\events\UnreadCountChangedEvent;
1214
use humhub\modules\notification\targets\MobileTargetProvider;
1315
use humhub\modules\notification\widgets\NotificationSettingsForm;
1416
use humhub\modules\web\pwa\controllers\ServiceWorkerController;
@@ -106,4 +108,25 @@ public static function onNotificationSettingsFormAfterRun(WidgetEvent $event)
106108
$event->result = RegisterDeviceTokenButton::widget() . $event->result;
107109
}
108110
}
111+
112+
/**
113+
* Pushes a delayed, exclusive-per-user job that sends the updated unread notification
114+
* count as a silent push to the user's devices, whenever the count changed.
115+
*
116+
* The delay collapses multiple count changes within a short time frame into a single
117+
* push carrying the final count
118+
*/
119+
public static function onUnreadCountChanged(UnreadCountChangedEvent $event): void
120+
{
121+
/** @var Module $module */
122+
$module = Yii::$app->getModule('fcm-push');
123+
124+
if (!$module->getDriverService()->hasConfiguredDriver()) {
125+
return;
126+
}
127+
128+
Yii::$app->queue->delay($module->silentUnreadNotificationCountPushDelay)->push(new SendSilentUnreadNotificationCountJob([
129+
'userId' => $event->user->id,
130+
]));
131+
}
109132
}

Module.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010
class Module extends \humhub\components\Module
1111
{
1212
public string $humhubProxySenderId = '21392898126';
13+
14+
/**
15+
* @var int delay in seconds before a silent unread notification count push is sent.
16+
* Multiple count changes for the same user within this window are collapsed into a
17+
* single push carrying the final count.
18+
* @since 2.2.9
19+
*/
20+
public int $silentUnreadNotificationCountPushDelay = 60 * 10;
1321
private ?ConfigureForm $configForm = null;
1422
private ?DriverService $driverService = null;
1523

config.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
use humhub\components\Controller;
77
use humhub\modules\fcmPush\Events;
8+
use humhub\modules\notification\events\UnreadCountChangedEvent;
89
use humhub\modules\notification\widgets\NotificationSettingsForm;
910
use humhub\modules\user\components\User;
1011
use humhub\widgets\LayoutAddons;
@@ -23,6 +24,7 @@
2324
[User::class, User::EVENT_AFTER_LOGIN, [Events::class, 'onAfterLogin']],
2425
[User::class, User::EVENT_AFTER_LOGOUT, [Events::class, 'onAfterLogout']],
2526
[NotificationSettingsForm::class, NotificationSettingsForm::EVENT_AFTER_RUN, [Events::class, 'onNotificationSettingsFormAfterRun']],
27+
[UnreadCountChangedEvent::class, UnreadCountChangedEvent::EVENT_UNREAD_COUNT_CHANGED, [Events::class, 'onUnreadCountChanged']],
2628
],
2729
'consoleControllerMap' => [
2830
'firebase' => 'humhub\modules\fcmPush\commands\SendController',

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Changelog
44
2.2.9 (Unreleased)
55
------------------
66
- Enh #102: Calculate the notification badge count internally and allow other modules to add their own counts via the new `MessagingService::EVENT_NOTIFICATION_COUNT` event; the `$notificationCount` parameter of `MessagingService::processMessage()` is deprecated and its value ignored
7+
- Enh #103: Send a silent push notification with the updated unread notification count whenever it changes (e.g. after reading/seeing a notification or conversation message), so the app badge count stays in sync
78

89
2.2.8 (July 15, 2026)
910
---------------------

drivers/DriverInterface.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ public function __construct(ConfigureForm $config);
1212

1313
public function processCloudMessage(array $tokens, string $title, string $body, ?string $url, ?string $imageUrl, ?int $notificationCount): SendReport;
1414

15+
/**
16+
* Sends a silent (data-only) message that only carries the unread notification count,
17+
* without a visible notification. Used to keep the app badge count in sync.
18+
*
19+
* @since 2.2.9
20+
*/
21+
public function processSilentCloudMessage(array $tokens, ?int $notificationCount): SendReport;
22+
1523
public function getSenderId(): string;
1624

1725
public function isConfigured(): bool;

drivers/Fcm.php

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,27 @@ public function __construct(private ConfigureForm $config)
3636
}
3737

3838
public function processCloudMessage(array $tokens, string $title, string $body, ?string $url, ?string $imageUrl, ?int $notificationCount): SendReport
39+
{
40+
$message = CloudMessage::new()
41+
->withNotification(Notification::create($title, $body)) // $imageUrl is intentionally omitted — including it would show a duplicate logo on branded apps
42+
->withWebPushConfig(['fcm_options' => ['link' => $url]])
43+
->withData(['url' => $url, 'notification_count' => $notificationCount])
44+
->withHighestPossiblePriority();
45+
46+
return $this->sendMulticast($message, $tokens);
47+
}
48+
49+
public function processSilentCloudMessage(array $tokens, ?int $notificationCount): SendReport
50+
{
51+
// Data-only message (no notification payload) so the app updates the badge count
52+
// in the background without displaying a visible notification.
53+
$message = CloudMessage::new()
54+
->withData(['notification_count' => $notificationCount]);
55+
56+
return $this->sendMulticast($message, $tokens);
57+
}
58+
59+
private function sendMulticast(CloudMessage $message, array $tokens): SendReport
3960
{
4061
if ($this->messaging === null) {
4162
Module::registerAutoloader();
@@ -44,12 +65,6 @@ public function processCloudMessage(array $tokens, string $title, string $body,
4465
$this->messaging = $factory->createMessaging();
4566
}
4667

47-
$message = CloudMessage::new()
48-
->withNotification(Notification::create($title, $body)) // $imageUrl is intentionally omitted — including it would show a duplicate logo on branded apps
49-
->withWebPushConfig(['fcm_options' => ['link' => $url]])
50-
->withData(['url' => $url, 'notification_count' => $notificationCount])
51-
->withHighestPossiblePriority();
52-
5368
try {
5469
$report = $this->messaging->sendMulticast($message, $tokens);
5570
} catch (MessagingException $e) {

drivers/Proxy.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,29 @@ public function createRequest()
4242

4343
public function processCloudMessage(array $tokens, string $title, string $body, ?string $url, ?string $imageUrl, ?int $notificationCount): SendReport
4444
{
45-
$data = [
45+
return $this->sendCloudMessage([
4646
'tokens' => $tokens,
4747
'title' => $title,
4848
'body' => $body,
4949
'iconUrl' => $imageUrl,
5050
'url' => $url,
5151
'notificationCount' => $notificationCount,
52-
];
52+
]);
53+
}
5354

55+
public function processSilentCloudMessage(array $tokens, ?int $notificationCount): SendReport
56+
{
57+
// A silent message carries no title/body, so the relay dispatches a data-only
58+
// message that only updates the app badge count without showing a notification.
59+
return $this->sendCloudMessage([
60+
'tokens' => $tokens,
61+
'notificationCount' => $notificationCount,
62+
'silent' => true,
63+
]);
64+
}
65+
66+
private function sendCloudMessage(array $data): SendReport
67+
{
5468
$response = $this->post('/push', $data)->send();
5569

5670
if (!$response->isOk || empty($response->data['success'])) {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace humhub\modules\fcmPush\jobs;
4+
5+
use humhub\modules\fcmPush\Module;
6+
use humhub\modules\fcmPush\services\MessagingService;
7+
use humhub\modules\queue\ActiveJob;
8+
use humhub\modules\queue\interfaces\ExclusiveJobInterface;
9+
use humhub\modules\user\models\User;
10+
use Yii;
11+
12+
/**
13+
* Sends a silent (data-only) push notification carrying the current unread notification
14+
* count to a user's registered devices, so the mobile / PWA app can update its badge count.
15+
*
16+
* This job is exclusive per user and is pushed with a delay (see the FCM push event handler),
17+
* so multiple unread-count changes within a short time frame are collapsed into a single push
18+
* carrying the final, up-to-date count.
19+
*
20+
* @since 2.2.9
21+
*/
22+
class SendSilentUnreadNotificationCountJob extends ActiveJob implements ExclusiveJobInterface
23+
{
24+
/**
25+
* @var int the id of the user whose unread notification count has changed
26+
*/
27+
public $userId;
28+
29+
/**
30+
* @inheritdoc
31+
*/
32+
public function getExclusiveJobId()
33+
{
34+
return 'fcm-push.silent-unread-count.' . $this->userId;
35+
}
36+
37+
/**
38+
* @inheritdoc
39+
*/
40+
public function run()
41+
{
42+
$user = User::findOne(['id' => $this->userId]);
43+
if ($user === null) {
44+
return;
45+
}
46+
47+
/** @var Module $module */
48+
$module = Yii::$app->getModule('fcm-push');
49+
50+
(new MessagingService($module->getConfigureForm()))
51+
->sendSilentUnreadNotificationCount($user);
52+
}
53+
}

module.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
],
1010
"homepage": "https://github.com/humhub/fcm-push",
1111
"humhub": {
12-
"minVersion": "1.18"
12+
"minVersion": "1.19"
1313
},
14-
"version": "2.2.8",
14+
"version": "2.2.9",
1515
"screenshots": [
1616
"resources/screenshot1.PNG",
1717
"resources/screenshot2.PNG"

services/MessagingService.php

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
namespace humhub\modules\fcmPush\services;
44

55
use humhub\components\Event;
6+
use humhub\modules\fcmPush\components\SendReport;
67
use humhub\modules\fcmPush\drivers\DriverInterface;
78
use humhub\modules\fcmPush\events\NotificationCountEvent;
89
use humhub\modules\fcmPush\models\ConfigureForm;
910
use humhub\modules\notification\components\BaseNotification;
1011
use humhub\modules\notification\models\Notification as NotificationHumHub;
1112
use humhub\modules\user\models\User;
12-
use humhub\modules\web\pwa\widgets\SiteIcon;
1313
use Yii;
1414
use yii\helpers\Url;
1515

@@ -39,32 +39,38 @@ public function processNotification(BaseNotification $baseNotification, User $us
3939
Yii::$app->name,
4040
$baseNotification->text(),
4141
Url::to(['/notification/entry', 'id' => $baseNotification->record->id], true),
42-
$this->getSiteIconUrl(180),
42+
Yii::$app->img->icon->getUrl(['square' => 180]),
4343
);
4444
}
4545

4646
/**
47-
* Returns the site icon URL for the given square size.
48-
*
49-
* HumHub 1.19 removed the {@see SiteIcon} widget and replaced it with the
50-
* AssetImage registry exposed as `Yii::$app->img`. This module still supports
51-
* HumHub 1.18 (see `module.json` `humhub.minVersion`), so fall back to the
52-
* legacy widget when the registry is not available.
47+
* @param int|null $notificationCount deprecated since 2.2.9, will be removed in a future version.
48+
* The value is ignored — the count is now calculated by {@see getNotificationCount()}.
5349
*/
54-
private function getSiteIconUrl(int $size): ?string
50+
public function processMessage(User $user, string $title, string $body, ?string $url, ?string $imageUrl, ?int $notificationCount = null)
5551
{
56-
if (Yii::$app->has('img')) {
57-
return Yii::$app->img->icon->getUrl(['square' => $size]);
58-
}
52+
$tokenService = new TokenService();
53+
$notificationCount = $this->getNotificationCount($user);
5954

60-
return SiteIcon::getUrl($size);
55+
foreach ($this->drivers as $driver) {
56+
$tokens = $tokenService->getTokensForUser($user, $driver);
57+
if (empty($tokens)) {
58+
continue;
59+
}
60+
61+
$report = $driver->processCloudMessage($tokens, $title, $body, $url, $imageUrl, $notificationCount);
62+
$this->removeFailedTokens($tokenService, $report);
63+
}
6164
}
6265

6366
/**
64-
* @param int|null $notificationCount deprecated since 2.2.9, will be removed in a future version.
65-
* The value is ignored — the count is now calculated by {@see getNotificationCount()}.
67+
* Sends a silent (data-only) push notification carrying the current unread notification
68+
* count to the user's registered devices, so the mobile app (Proxy driver) and PWA app
69+
* (Fcm driver) can update their badge count without displaying a visible notification.
70+
*
71+
* @since 2.2.9
6672
*/
67-
public function processMessage(User $user, string $title, string $body, ?string $url, ?string $imageUrl, ?int $notificationCount = null)
73+
public function sendSilentUnreadNotificationCount(User $user): void
6874
{
6975
$tokenService = new TokenService();
7076
$notificationCount = $this->getNotificationCount($user);
@@ -75,14 +81,20 @@ public function processMessage(User $user, string $title, string $body, ?string
7581
continue;
7682
}
7783

78-
$report = $driver->processCloudMessage($tokens, $title, $body, $url, $imageUrl, $notificationCount);
84+
$report = $driver->processSilentCloudMessage($tokens, $notificationCount);
85+
$this->removeFailedTokens($tokenService, $report);
86+
}
87+
}
7988

80-
// Remove tokens that Firebase rejected (e.g. from an uninstalled / reinstalled app).
81-
// This prevents stale tokens from accumulating and blocking future deliveries.
82-
foreach ($report->failedTokens as $failedToken) {
83-
Yii::warning("Removing failed/unregistered FCM token: $failedToken", 'fcm-push');
84-
$tokenService->deleteToken($failedToken);
85-
}
89+
/**
90+
* Removes tokens that Firebase rejected (e.g. from an uninstalled / reinstalled app).
91+
* This prevents stale tokens from accumulating and blocking future deliveries.
92+
*/
93+
private function removeFailedTokens(TokenService $tokenService, SendReport $report): void
94+
{
95+
foreach ($report->failedTokens as $failedToken) {
96+
Yii::warning("Removing failed/unregistered FCM token: $failedToken", 'fcm-push');
97+
$tokenService->deleteToken($failedToken);
8698
}
8799
}
88100

0 commit comments

Comments
 (0)