• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / #2517

10 Dec 2025 05:39PM UTC coverage: 90.273%. Remained the same
#2517

push

php-coveralls

edwh
Fix duplicate push notifications for unviewed chat messages.

The legacy notification path uses lastmsgseen (view tracking) instead of
lastmsgnotified (notification tracking). When triggered days later by cron
jobs or other events, messages that were already notified but not viewed
would get re-notified.

Fix: In chat-specific context (chatid provided), filter FCM notifications
from legacy path after notifyIndividualMessages() has already checked them
using lastmsgnotified.

4 of 4 new or added lines in 1 file covered. (100.0%)

11 existing lines in 1 file now uncovered.

26570 of 29433 relevant lines covered (90.27%)

31.51 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.06
/include/user/PushNotifications.php
1
<?php
2
namespace Freegle\Iznik;
3

4
use Minishlink\WebPush\WebPush;
5
use Minishlink\WebPush\Subscription;
6
use Pheanstalk\Pheanstalk;
7
use Kreait\Firebase\Factory;
8
use Kreait\Firebase\ServiceAccount;
9
use Kreait\Firebase\Messaging\CloudMessage;
10
use Kreait\Firebase\Messaging\AndroidConfig;
11
use Kreait\Firebase\Messaging\ApnsConfig;
12
use Kreait\Firebase\Exception\Messaging\InvalidMessage;
13

14
class PushNotifications
15
{
16
    const PUSH_GOOGLE = 'Google'; // Obsolete
17
    const PUSH_FIREFOX = 'Firefox'; // Obsolete
18
    const PUSH_TEST = 'Test';
19
    const PUSH_FCM_ANDROID = 'FCMAndroid';
20
    const PUSH_FCM_IOS = 'FCMIOS';
21
    const APPTYPE_MODTOOLS = 'ModTools';
22
    const APPTYPE_USER = 'User';
23
    const PUSH_BROWSER_PUSH = 'BrowserPush';
24

25
    // Notification categories - these map to Android channels and iOS categories
26
    const CATEGORY_CHAT_MESSAGE = 'CHAT_MESSAGE';
27
    const CATEGORY_CHITCHAT_COMMENT = 'CHITCHAT_COMMENT';
28
    const CATEGORY_CHITCHAT_REPLY = 'CHITCHAT_REPLY';
29
    const CATEGORY_CHITCHAT_LOVED = 'CHITCHAT_LOVED';
30
    const CATEGORY_POST_REMINDER = 'POST_REMINDER';
31
    const CATEGORY_NEW_POSTS = 'NEW_POSTS';
32
    const CATEGORY_COLLECTION = 'COLLECTION';
33
    const CATEGORY_EVENT_SUMMARY = 'EVENT_SUMMARY';
34
    const CATEGORY_EXHORT = 'EXHORT';
35

36
    // Category configuration: iOS interruption level, Android channel ID, Android priority
37
    const CATEGORIES = [
38
        self::CATEGORY_CHAT_MESSAGE => [
39
            'ios_interruption' => 'time-sensitive',
40
            'android_channel' => 'chat_messages',
41
            'android_priority' => 'high'
42
        ],
43
        self::CATEGORY_CHITCHAT_COMMENT => [
44
            'ios_interruption' => 'passive',
45
            'android_channel' => 'social',
46
            'android_priority' => 'normal'
47
        ],
48
        self::CATEGORY_CHITCHAT_REPLY => [
49
            'ios_interruption' => 'passive',
50
            'android_channel' => 'social',
51
            'android_priority' => 'normal'
52
        ],
53
        self::CATEGORY_CHITCHAT_LOVED => [
54
            'ios_interruption' => 'passive',
55
            'android_channel' => 'social',
56
            'android_priority' => 'normal'
57
        ],
58
        self::CATEGORY_POST_REMINDER => [
59
            'ios_interruption' => 'active',
60
            'android_channel' => 'reminders',
61
            'android_priority' => 'normal'
62
        ],
63
        self::CATEGORY_NEW_POSTS => [
64
            'ios_interruption' => 'passive',
65
            'android_channel' => 'new_posts',
66
            'android_priority' => 'normal'
67
        ],
68
        self::CATEGORY_COLLECTION => [
69
            'ios_interruption' => 'active',
70
            'android_channel' => 'reminders',
71
            'android_priority' => 'normal'
72
        ],
73
        self::CATEGORY_EVENT_SUMMARY => [
74
            'ios_interruption' => 'passive',
75
            'android_channel' => 'social',
76
            'android_priority' => 'normal'
77
        ],
78
        self::CATEGORY_EXHORT => [
79
            'ios_interruption' => 'passive',
80
            'android_channel' => 'tips',
81
            'android_priority' => 'normal'
82
        ]
83
    ];
84

85
    private $dbhr, $dbhm, $log, $pheanstalk = NULL, $firebase = NULL, $messaging = NULL;
86

87
    function __construct(LoggedPDO $dbhr, LoggedPDO $dbhm)
88
    {
89
        $this->dbhr = $dbhr;
652✔
90
        $this->dbhm = $dbhm;
652✔
91
        $this->log = new Log($dbhr, $dbhm);
652✔
92

93
        if (file_exists('/etc/firebase.json')) {
652✔
94
            $factory = (new Factory)
×
95
                ->withServiceAccount('/etc/firebase.json');
×
96
            $this->messaging = $factory->createMessaging();
×
97
        }
98
    }
99

100
    public function get($userid)
101
    {
102
        # Cache the notification - saves a DB call in GET of session, which is very common.
103
        $ret = Utils::presdef('notification', $_SESSION, []);
21✔
104

105
        if (!$ret) {
21✔
106
            $sql = "SELECT * FROM users_push_notifications WHERE userid = ?;";
21✔
107
            $notifs = $this->dbhr->preQuery($sql, [$userid]);
21✔
108
            foreach ($notifs as &$notif) {
21✔
109
                $notif['added'] = Utils::ISODate($notif['added']);
3✔
110
                $ret[] = $notif;
3✔
111
            }
112

113
            $_SESSION['notification'] = $ret;
21✔
114
        }
115

116
        return ($ret);
21✔
117
    }
118

119
    public function add($userid, $type, $val, $modtools = NULL)
120
    {
121
        if (is_null($modtools)) {
14✔
122
            $modtools = Session::modtools();
2✔
123
        }
124

125
        $rc = NULL;
14✔
126

127
        if ($userid) {
14✔
128
            $apptype = $modtools ? PushNotifications::APPTYPE_MODTOOLS : PushNotifications::APPTYPE_USER;
14✔
129
            $sql = "INSERT INTO users_push_notifications (`userid`, `type`, `subscription`, `apptype`) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE userid = ?, type = ?, apptype = ?;";
14✔
130
            $rc = $this->dbhm->preExec($sql, [$userid, $type, $val, $apptype, $userid, $type, $apptype]);
14✔
131
            Session::clearSessionCache();
14✔
132
        }
133

134
        return ($rc);
14✔
135
    }
136

137
    public function remove($userid)
138
    {
139
        $sql = "DELETE FROM users_push_notifications WHERE userid = ?;";
2✔
140
        $rc = $this->dbhm->preExec($sql, [$userid]);
2✔
141
        return ($rc);
2✔
142
    }
143

144
    public function uthook($rc = NULL)
145
    {
146
        # Mocked in UT to force an exception.
147
        return ($rc);
125✔
148
    }
149

150
    private function queueSend($userid, $type, $params, $endpoint, $payload)
151
    {
152
        #error_log("queueSend $userid $endpoint params " . var_export($params, TRUE));
153
        try {
154
            $this->uthook();
4✔
155

156
            if (!$this->pheanstalk) {
3✔
157
                $this->pheanstalk = Pheanstalk::create(PHEANSTALK_SERVER);
3✔
158
            }
159

160
            $str = json_encode(array(
3✔
161
                'type' => 'webpush',
3✔
162
                'notiftype' => $type,
3✔
163
                'queued' => microtime(TRUE),
3✔
164
                'userid' => $userid,
3✔
165
                'params' => $params,
3✔
166
                'endpoint' => $endpoint,
3✔
167
                'payload' => $payload,
3✔
168
                'ttr' => Utils::PHEANSTALK_TTR
3✔
169
            ));
3✔
170

171
            $id = $this->pheanstalk->put($str);
3✔
172
        } catch (\Exception $e) {
4✔
173
            error_log("Beanstalk exception " . $e->getMessage());
4✔
174
            $this->pheanstalk = NULL;
4✔
175
        }
176
    }
177

178
    public function executeSend($userid, $notiftype, $params, $endpoint, $payload)
179
    {
180
        #error_log("Execute send type $notiftype params " . var_export($params, TRUE) . " payload " . var_export($payload, TRUE) . " endpoint $endpoint");
181
        try {
182
            error_log("notiftype " . $notiftype . " userid " . $userid);
3✔
183

184
            switch ($notiftype) {
185
                case PushNotifications::PUSH_FCM_ANDROID:
186
                case PushNotifications::PUSH_FCM_IOS:
187
                case PushNotifications::PUSH_BROWSER_PUSH:
188
                    {
2✔
189
                        # Everything is in one array as passed to this function; split it out into what we need
190
                        # for FCM.
191
                        #error_log("FCM notif " . var_export($payload, TRUE));
192
                        $data = $payload;
2✔
193

194
                        # We can only have key => string, so the chatids needs to be converted from an array to
195
                        # a string (if it isn't already).
196
                        if (is_array($data['chatids'])) {
2✔
197
                            $data['chatids'] = implode(',', $data['chatids']);
2✔
198
                        } else {
2✔
199
                            $data['chatids'] = (string)$data['chatids'];
2✔
200
                        }
2✔
201

202
                        # And anything that isn't a string needs to pretend to be one.  How dull.
203
                        foreach ($data as $key => $val) {
2✔
204
                            if (gettype($val) !== 'string') {
2✔
205
                                $data[$key] = "$val";
2✔
206
                            }
2✔
207
                        }
2✔
208

209
                        $data['notId'] = (string)floor(microtime(TRUE));
2✔
210

211
                        #error_log("Data is " . var_export($data, TRUE));
212

213
                        if ($notiftype == PushNotifications::PUSH_FCM_ANDROID) {
2✔
214
                            # Need to omit notification for reasons to do with Cordova plugin.
215
                            if ($payload['count']) {
2✔
216
                                $data['content-available'] = "1";
2✔
217
                            }
2✔
218

219
                            $message = CloudMessage::fromArray([
2✔
220
                                'token' => $endpoint,
2✔
221
                                'data' => $data
2✔
222
                            ]);
2✔
223

224
                            # Build Android config
225
                            $androidConfig = [
2✔
226
                                'ttl' => '3600s',
2✔
227
                                'priority' => 'normal'
2✔
228
                            ];
2✔
229

230
                            $category = Utils::presdef('category', $payload, NULL);
2✔
231
                            $hasChannelId = !empty($data['channel_id']);
2✔
232

233
                            if ($category && isset(self::CATEGORIES[$category])) {
2✔
234
                                $categoryConfig = self::CATEGORIES[$category];
2✔
235
                                $androidConfig['priority'] = $categoryConfig['android_priority'];
2✔
236

237
                                # NEW notifications (with channel_id): Data-only, app creates notification
238
                                if ($hasChannelId) {
1✔
239
                                    # Add category to data so app can add action buttons
240
                                    $data['category'] = $category;
2✔
241
                                    # Update the message with the modified data
242
                                    $message = CloudMessage::fromArray([
2✔
243
                                        'token' => $endpoint,
2✔
244
                                        'data' => $data
2✔
245
                                    ]);
2✔
246
                                }
2✔
247
                                # LEGACY notifications (no channel_id): Use androidConfig notification for auto-display
248
                                else {
2✔
249
                                    $androidConfig['notification'] = [
2✔
250
                                        'channel_id' => $categoryConfig['android_channel']
2✔
251
                                    ];
2✔
252

253
                                    # Add thread tag for notification grouping
254
                                    $threadId = Utils::presdef('threadId', $payload, NULL);
2✔
255
                                    if ($threadId) {
1✔
256
                                        $androidConfig['notification']['tag'] = $threadId;
2✔
257
                                    }
2✔
258

259
                                    # Add image for rich notifications
260
                                    $image = Utils::presdef('image', $payload, NULL);
2✔
261
                                    if ($image && strpos($image, 'http') === 0) {
1✔
262
                                        $androidConfig['notification']['image'] = $image;
2✔
263
                                    }
2✔
264
                                }
2✔
265
                            }
2✔
266

267
                            $message = $message->withAndroidConfig($androidConfig);
2✔
268
                        } else {
2✔
269
                            # For IOS and browser push notifications.
270
                            $ios = [
2✔
271
                                'token' => $endpoint,
2✔
272
                                'data' => $data
2✔
273
                            ];
2✔
274

275
                            if (!empty($payload['title'])) {   // Don't set notification if clearing
2✔
276
                                $iostitle = $payload['title'];
2✔
277
                                $iosbody = $payload['message'];
2✔
278

279
                                if (empty($iosbody)) {  // older iOS only shows body and doesn't show if body empty
2✔
280
                                    $iosbody = $iostitle;
2✔
281
                                    $iostitle = ' ';
2✔
282
                                }
2✔
283

284
                                $ios['notification'] = [
2✔
285
                                    'title' => $iostitle,
2✔
286
                                    'body' => $iosbody,
2✔
287
                                ];
2✔
288

289
                                if ($notiftype == PushNotifications::PUSH_BROWSER_PUSH) {
2✔
290
                                    $ios['notification']['webpush'] = [
2✔
291
                                        'fcm_options' => [
2✔
292
                                            'link' => $payload['route']
2✔
293
                                        ]
2✔
294
                                    ];
2✔
295
                                }
2✔
296
                            }
2✔
297

298
                            #error_log("ios is " . var_export($ios, TRUE));
299
                            $message = CloudMessage::fromArray($ios);
2✔
300

301
                            $aps = [
2✔
302
                                'badge' => $payload['count'],
2✔
303
                                'sound' => "default"
2✔
304
                            ];
2✔
305

306
                            # Add category, interruption-level and thread-id if category is set (iOS 15+)
307
                            $category = Utils::presdef('category', $payload, NULL);
2✔
308
                            if ($category && isset(self::CATEGORIES[$category])) {
2✔
309
                                $categoryConfig = self::CATEGORIES[$category];
2✔
310
                                # Category is required for iOS action buttons to work
311
                                $aps['category'] = $category;
2✔
312
                                $aps['interruption-level'] = $categoryConfig['ios_interruption'];
2✔
313

314
                                # Add thread-id for notification grouping
315
                                $threadId = Utils::presdef('threadId', $payload, NULL);
2✔
316
                                if ($threadId) {
1✔
317
                                    $aps['thread-id'] = $threadId;
2✔
318
                                }
2✔
319

320
                                # Add channel_id to data so the app can filter on it
321
                                $data['channel_id'] = $categoryConfig['android_channel'];
2✔
322
                                $ios['data'] = $data;
2✔
323
                                $message = CloudMessage::fromArray($ios);
2✔
324
                            }
2✔
325

326
                            # For iOS, add image via mutable-content (requires Notification Service Extension)
327
                            $image = Utils::presdef('image', $payload, NULL);
2✔
328
                            if ($image && strpos($image, 'http') === 0) {
2✔
329
                                $aps['mutable-content'] = 1;
2✔
330
                                # Image URL goes in data for the Service Extension to fetch
331
                                $data['imageUrl'] = $image;
2✔
332
                                $ios['data'] = $data;
2✔
333
                                $message = CloudMessage::fromArray($ios);
2✔
334
                            }
2✔
335

336
                            $params = [
2✔
337
                                'headers' => [
2✔
338
                                    'apns-priority' => '10',
2✔
339
                                ],
2✔
340
                                'payload' => [
2✔
341
                                    'aps' => $aps
2✔
342
                                ],
2✔
343
                            ];
2✔
344

345
                            #error_log("Send params " . var_export($params, TRUE));
346
                            #error_log("Send payload " . var_export($ios, TRUE));
347
                            $message = $message->withApnsConfig($params);
2✔
348
                        }
2✔
349

350
                        try {
2✔
351
                            if ($this->messaging) {
2✔
352
                                $this->messaging->validate($message);
2✔
353
                            }
2✔
354
                        } catch (InvalidMessage $e) {
×
355
                            # We might not want to remove the subscription.  Check the nature of the error
356
                            # and (for now) record unknown ones to check.
357
                            $error = $e->errors()['error'];
2✔
358
                            file_put_contents('/tmp/fcmerrors', date(DATE_RFC2822) . ': ' . $userid . ' - ' . $endpoint . ' - ' . var_export($error, TRUE) . "\r\n", FILE_APPEND);
2✔
359
                            error_log("FCM InvalidMessage " . var_export($error, TRUE));
2✔
360
                            $errorCode = 'CODE NOT FOUND';
2✔
361
                            if (array_key_exists('errorCode', $error['details'][0])) {
×
362
                                $errorCode = $error['details'][0]['errorCode'];
2✔
363
                            }
2✔
364
                            error_log("FCM errorCode " . $errorCode);
2✔
365

366
                            if ($errorCode == 'UNREGISTERED') {
×
367
                                # We do want to remove the subscription in this case.
368
                                throw new \Exception($errorCode);
×
369
                            }
2✔
370

371
                            foreach ($error['details'] as $detail) {
×
372
                                if (array_key_exists('fieldViolations', $detail)) {
×
373
                                    if ($detail['fieldViolations'][0]['description'] == 'Invalid registration token') {
×
374
                                        # We do want to remove the subscription in this case.
375
                                        throw new \Exception($detail['fieldViolations'][0]['description']);
×
376
                                    }
2✔
377
                                    if ($detail['fieldViolations'][0]['description'] == 'The registration token is not a valid FCM registration token') {
×
378
                                        # We do want to remove the subscription in this case.
379
                                        throw new \Exception($detail['fieldViolations'][0]['description']);
×
380
                                    }
2✔
381
                                }
2✔
382
                            }
2✔
383

384
                            $rc = TRUE; // Problem is ignored and subscription/token NOT removed: eyeball logs to check
2✔
385
                            break;
2✔
386
                        }
2✔
387

388
                        if ($this->messaging) {
2✔
389
                            $ret = $this->messaging->send($message);
2✔
390
                            error_log("FCM send " . var_export($ret, TRUE));
2✔
391
                        }
2✔
392

393
                        $rc = TRUE;
2✔
394
                        break;
2✔
395
                    }
2✔
396
                case PushNotifications::PUSH_GOOGLE:
397
                case PushNotifications::PUSH_FIREFOX:
398
                    $params = $params ? $params : [];
2✔
399
                    $webPush = new WebPush($params);
2✔
400
                    $subscription = Subscription::create([
2✔
401
                                                           "endpoint" => $endpoint,
2✔
402
                                                       ]);
2✔
403
                    #error_log("Send params " . var_export($params, TRUE) . " " . ($payload['count'] > 0) . "," . (!is_null($payload['title'])));
404
                    if (($payload && ($payload['count'] > 0) && (!is_null($payload['title'])))) {
2✔
405
                        $rc = $webPush->queueNotification($subscription, $payload['title']);
1✔
406
                    } else
407
                        $rc = TRUE;
2✔
408
                    break;
2✔
409
            }
410

411
            #error_log("Returned " . var_export($rc, TRUE) . " for $userid type $notiftype $endpoint payload " . var_export($payload, TRUE));
412
            $rc = $this->uthook($rc);
3✔
413
        } catch (\Exception $e) {
3✔
414
            $rc = ['exception' => $e->getMessage()];
3✔
415
            #error_log("push exc " . var_export($e, TRUE));
416
            #error_log("push exc " . $e->getMessage());
417
            error_log("Push exception {$rc['exception']}");
3✔
418
        }
419

420
        if ($rc !== TRUE) {
3✔
421
            error_log("Push Notification to $userid failed with " . var_export($rc, TRUE));
3✔
422
            $this->dbhm->preExec("DELETE FROM users_push_notifications WHERE userid = ? AND subscription = ?;", [$userid, $endpoint]);
3✔
423
        } else {
424
            # Don't log - lots of these.
425
            $this->dbhm->preExec("UPDATE users_push_notifications SET lastsent = NOW() WHERE userid = ? AND subscription = ?;", [$userid, $endpoint], FALSE);
2✔
426
        }
427

428
        return $rc;
3✔
429
    }
430

431
    private function notifyIndividualMessages($userid, $notifs, $modtools, $chatid = NULL) {
432
        // Send individual per-message notifications for admin users (new rich format)
433
        // If $chatid is specified, only send notifications for that specific chat
434
        $count = 0;
3✔
435
        $u = User::get($this->dbhr, $this->dbhm, $userid);
3✔
436
        $email = $u->getEmailPreferred();
3✔
437

438
        // Get unread chat messages for this user that haven't been notified yet
439
        $chatFilter = $chatid ? "AND cm.chatid = ?" : "";
3✔
440
        $params = [$userid, $userid, $userid, $userid];
3✔
441
        if ($chatid) {
3✔
442
            $params[] = $chatid;
2✔
443
        }
444

445
        // Only check lastmsgnotified, not lastmsgseen - we want to send notifications even if the app
446
        // has marked messages as "seen" (which can happen due to background polling or incorrect client logic)
447
        $chats = $this->dbhr->preQuery("
3✔
448
            SELECT cm.id, cm.chatid, cm.userid as senderid, cm.message, cm.date
449
            FROM chat_messages cm
450
            INNER JOIN chat_rooms cr ON cm.chatid = cr.id
451
            LEFT JOIN chat_roster roster ON roster.chatid = cm.chatid AND roster.userid = ?
452
            WHERE (cr.user1 = ? OR cr.user2 = ?)
453
            AND cm.userid != ?
454
            AND cm.reviewrequired = 0
455
            AND cm.reviewrejected = 0
456
            AND cm.id > COALESCE(roster.lastmsgnotified, 0)
457
            AND cm.date >= DATE_SUB(NOW(), INTERVAL 7 DAY)
458
            $chatFilter
3✔
459
            ORDER BY cm.date ASC
460
            LIMIT 20
461
        ", $params);
3✔
462

463
        $lastMsgId = 0;
3✔
464
        $chatidsNotified = [];
3✔
465

466
        foreach ($chats as $chat) {
3✔
467
            // Get chat room info including icon
468
            $r = new ChatRoom($this->dbhr, $this->dbhm, $chat['chatid']);
2✔
469
            $atts = $r->getPublic($u);
2✔
470
            $icon = Utils::presdef('icon', $atts, USERLOGO);
2✔
471

472
            // Get the other user's name (the sender)
473
            if (isset($atts['user1']) && $atts['user1']['id'] == $chat['senderid']) {
2✔
474
                $sendername = $atts['user1']['displayname'];
×
475
            } elseif (isset($atts['user2']) && $atts['user2']['id'] == $chat['senderid']) {
2✔
476
                $sendername = $atts['user2']['displayname'];
2✔
477
            } else {
478
                $sendername = 'Someone';
×
479
            }
480

481
            $message = Utils::decodeEmojis($chat['message']);
2✔
482
            $messagePreview = strlen($message) > 50 ? (substr($message, 0, 50) . "...") : $message;
2✔
483
            $message = strlen($message) > 256 ? (substr($message, 0, 256) . "...") : $message;
2✔
484

485
            error_log("Notify push chat #{$chat['chatid']} $email for $userid message {$chat['id']}: $messagePreview");
2✔
486

487
            // Get channel configuration for this category
488
            $categoryConfig = self::CATEGORIES[self::CATEGORY_CHAT_MESSAGE];
2✔
489

490
            $payload = [
2✔
491
                'badge' => count($chats),
2✔
492
                'count' => count($chats),
2✔
493
                'chatcount' => count($chats),
2✔
494
                'notifcount' => 0,
2✔
495
                'title' => $sendername,
2✔
496
                'message' => $message,
2✔
497
                'chatids' => [(string)$chat['chatid']],  // Array with single chat ID for implode compatibility
2✔
498
                'chatid' => (string)$chat['chatid'],  // Individual chat ID for this message
2✔
499
                'messageid' => (string)$chat['id'],    // Message ID for uniqueness
2✔
500
                // Use chatid for notId so new messages replace old ones for the same chat, preventing notification flooding
501
                // even when sending per-message notifications. Each chat gets one notification slot that updates with latest message.
502
                'notId' => (int)$chat['chatid'],
2✔
503
                'timestamp' => strtotime($chat['date']), // Unix timestamp for sorting
2✔
504
                'content-available' => 1,
2✔
505
                'image' => $icon,
2✔
506
                'modtools' => $modtools,
2✔
507
                'sound' => 'default',
2✔
508
                'route' => '/chats/' . $chat['chatid'],
2✔
509
                'category' => self::CATEGORY_CHAT_MESSAGE,
2✔
510
                'channel_id' => $categoryConfig['android_channel'],  // Required for Android to use correct channel
2✔
511
                'threadId' => 'chat_' . $chat['chatid']
2✔
512
            ];
2✔
513

514
            foreach ($notifs as $notif) {
2✔
515
                $this->queueSend($userid, $notif['type'], [], $notif['subscription'], $payload);
2✔
516
                $count++;
2✔
517
            }
518

519
            // Track the highest message ID and chats we've notified
520
            $lastMsgId = max($lastMsgId, $chat['id']);
2✔
521
            if (!in_array($chat['chatid'], $chatidsNotified)) {
2✔
522
                $chatidsNotified[] = $chat['chatid'];
2✔
523
            }
524
        }
525

526
        // Update lastmsgnotified for each chat we sent notifications for
527
        foreach ($chatidsNotified as $cid) {
3✔
528
            $this->dbhm->preExec("
2✔
529
                INSERT INTO chat_roster (chatid, userid, lastmsgnotified, date)
530
                VALUES (?, ?, ?, NOW())
531
                ON DUPLICATE KEY UPDATE lastmsgnotified = ?
532
            ", [$cid, $userid, $lastMsgId, $lastMsgId]);
2✔
533
        }
534

535
        return $count;
3✔
536
    }
537

538
    public function notify($userid, $modtools, $browserPush = FALSE, $chatid = NULL)
539
    {
540
        $count = 0;
608✔
541
        $u = User::get($this->dbhr, $this->dbhm, $userid);
608✔
542
        $proceedpush = TRUE; // $u->notifsOn(User::NOTIFS_PUSH);
608✔
543
        #error_log("Notify $userid, push on $proceedpush MT $modtools browserPush $browserPush chatid $chatid");
544

545
        if ($browserPush) {
608✔
546
            $notifs = $this->dbhr->preQuery("SELECT * FROM users_push_notifications WHERE userid = ? AND `type` = ?;", [
×
547
                $userid,
×
548
                PushNotifications::PUSH_BROWSER_PUSH
×
549
            ]);
×
550
        } else {
551
            $notifs = $this->dbhr->preQuery("SELECT * FROM users_push_notifications WHERE userid = ? AND apptype = ?;", [
608✔
552
                $userid,
608✔
553
                $modtools ? PushNotifications::APPTYPE_MODTOOLS : PushNotifications::APPTYPE_USER
608✔
554
            ]);
608✔
555
        }
556

557
        // Send individual per-message notifications (new rich format with action buttons)
558
        // This handles chat messages with rich formatting
559
        if (!$modtools) {
608✔
560
            $appNotifs = array_filter($notifs, function($n) {
608✔
561
                return $n['type'] === PushNotifications::PUSH_FCM_ANDROID || $n['type'] === PushNotifications::PUSH_FCM_IOS;
3✔
562
            });
608✔
563

564
            if (count($appNotifs) > 0) {
608✔
565
                $individualCount = $this->notifyIndividualMessages($userid, $appNotifs, $modtools, $chatid);
3✔
566
                if ($individualCount > 0) {
3✔
567
                    // We sent chat message notifications, so we're done
568
                    return $individualCount;
2✔
569
                }
570
                // No new chat messages to notify, but we've already checked using lastmsgnotified.
571
                // If we're in a chat-specific context (chatid provided), remove app notifications
572
                // from $notifs so the legacy path (which uses lastmsgseen via getNotificationPayload)
573
                // doesn't re-send notifications for messages that were already notified but not yet
574
                // viewed by the user.
575
                // When chatid is NOT provided, we keep FCM in the legacy path to handle non-chat
576
                // notifications like TYPE_EXHORT.
577
                if ($chatid) {
3✔
578
                    $notifs = array_filter($notifs, function($n) {
1✔
579
                        return $n['type'] !== PushNotifications::PUSH_FCM_ANDROID && $n['type'] !== PushNotifications::PUSH_FCM_IOS;
1✔
580
                    });
1✔
581
                }
582
            }
583
        }
584

585
        foreach ($notifs as $notif) {
608✔
586
            #error_log("Consider notif {$notif['id']} proceed $proceedpush type {$notif['type']}");
587
            if ($proceedpush && in_array($notif['type'],
3✔
588
                    [PushNotifications::PUSH_FIREFOX, PushNotifications::PUSH_GOOGLE, PushNotifications::PUSH_BROWSER_PUSH]) ||
3✔
589
                in_array($notif['type'],
3✔
590
                        [PushNotifications::PUSH_FCM_ANDROID, PushNotifications::PUSH_FCM_IOS])) {
3✔
591
                #error_log("Send user $userid {$notif['subscription']} type {$notif['type']} for modtools $modtools");
592
                $payload = NULL;
3✔
593
                $params = [];
3✔
594

595
                list ($total, $chatcount, $notifscount, $title, $message, $chatids, $route, $category, $threadId, $image) = $u->getNotificationPayload($modtools);
3✔
596

597
                if ($title || $modtools || $total === 0) {
3✔
598
                    $message = ($total === 0) ? "" : $message;
3✔
599
                    if (is_null($message)) $message = "";
3✔
600

601
                    # badge and/or count are used by the app, possibly when it isn't running, to set the home screen badge.
602
                    $basePayload = [
3✔
603
                        'badge' => $total,
3✔
604
                        'count' => $total,
3✔
605
                        'chatcount' => $chatcount,
3✔
606
                        'notifcount' => $notifscount,
3✔
607
                        'title' => $title,
3✔
608
                        'message' => $message,
3✔
609
                        'chatids' => $chatids,
3✔
610
                        'content-available' => $total > 0,
3✔
611
                        'image' => $image ? $image : ($modtools ? "www/images/modtools_logo.png" : "www/images/user_logo.png"),
3✔
612
                        'modtools' => $modtools,
3✔
613
                        'sound' => 'default',
3✔
614
                        'route' => $route,
3✔
615
                        'threadId' => $threadId
3✔
616
                    ];
3✔
617

618
                    switch ($notif['type']) {
3✔
619
                        case PushNotifications::PUSH_GOOGLE:
620
                        {
1✔
621
                            $params = [
1✔
622
                                'GCM' => GOOGLE_PUSH_KEY
1✔
623
                            ];
1✔
624
                            break;
1✔
625
                        }
1✔
626
                    }
627

628
                    # For mobile apps (Android/iOS), send TWO notifications:
629
                    # 1. Legacy notification (no channel_id) - for old app versions
630
                    # 2. New notification (with channel_id) - for new app versions
631
                    # Each app version filters to only process one type, so no duplicates shown.
632
                    $isAppNotification = in_array($notif['type'], [PushNotifications::PUSH_FCM_ANDROID, PushNotifications::PUSH_FCM_IOS]);
3✔
633

634
                    if ($isAppNotification && $category) {
3✔
635
                        # Send legacy notification first (no category/channel_id)
636
                        $legacyPayload = $basePayload;
2✔
637
                        $this->queueSend($userid, $notif['type'], $params, $notif['subscription'], $legacyPayload);
2✔
638
                        $count++;
2✔
639

640
                        # Send new notification (with category/channel_id)
641
                        $newPayload = $basePayload;
2✔
642
                        $newPayload['category'] = $category;
2✔
643
                        $this->queueSend($userid, $notif['type'], $params, $notif['subscription'], $newPayload);
2✔
644
                        $count++;
2✔
645
                    } else {
646
                        # Browser push or no category - send single notification
647
                        $payload = $basePayload;
3✔
648
                        if ($category) {
3✔
649
                            $payload['category'] = $category;
2✔
650
                        }
651
                        $this->queueSend($userid, $notif['type'], $params, $notif['subscription'], $payload);
3✔
652
                        $count++;
3✔
653
                    }
654
                }
655
            }
656
        }
657

658
        return ($count);
608✔
659
    }
660

661
    public function notifyGroupMods($groupid)
662
    {
663
        $count = 0;
106✔
664
        $mods = $this->dbhr->preQuery("SELECT DISTINCT userid FROM memberships WHERE groupid = ? AND role IN ('Owner', 'Moderator');",
106✔
665
            [$groupid]);
106✔
666

667
        foreach ($mods as $mod) {
106✔
668
            $u = User::get($this->dbhr, $this->dbhm, $mod['userid']);
32✔
669
            $settings = $u->getGroupSettings($groupid);
32✔
670

671
            if (!array_key_exists('pushnotify', $settings) || $settings['pushnotify']) {
32✔
672
                #error_log("Notify {$mod['userid']} for $groupid notify " . Utils::presdef('pushnotify', $settings, TRUE) . " settings " . var_export($settings, TRUE));
673
                $count += $this->notify($mod['userid'], TRUE);
32✔
674
            }
675
        }
676

677
        return ($count);
106✔
678
    }
679

680
    public function pokeGroupMods($groupid, $data)
681
    {
682
        $count = 0;
32✔
683
        $mods = $this->dbhr->preQuery("SELECT userid FROM memberships WHERE groupid = ? AND role IN ('Owner', 'Moderator');",
32✔
684
            [$groupid]);
32✔
685

686
        foreach ($mods as $mod) {
32✔
687
            $this->poke($mod['userid'], $data, TRUE);
19✔
688
            $count++;
19✔
689
        }
690

691
        return ($count);
32✔
692
    }
693

694
    public function fsockopen($host, $port, &$errno, &$errstr)
695
    {
UNCOV
696
        $fp = @fsockopen($host, $port, $errno, $errstr);
×
UNCOV
697
        return ($fp);
×
698
    }
699

700
    public function fputs($fp, $str)
701
    {
UNCOV
702
        return (fputs($fp, $str));
×
703
    }
704

705
    public function poke($userid, $data, $modtools)
706
    {
707
        # We background this as it hits another server, so it may be slow (especially if that server is sick).
708
        try {
709
            $this->uthook();
124✔
710

711
            if (!$this->pheanstalk) {
124✔
712
                $this->pheanstalk = Pheanstalk::create(PHEANSTALK_SERVER);
124✔
713
            }
714

715
            $str = json_encode(array(
124✔
716
                'type' => 'poke',
124✔
717
                'queued' => microtime(TRUE),
124✔
718
                'groupid' => $userid,
124✔
719
                'data' => $data,
124✔
720
                'modtools' => $modtools,
124✔
721
                'ttr' => Utils::PHEANSTALK_TTR
124✔
722
            ));
124✔
723

724
            $this->pheanstalk->put($str);
124✔
725
            $ret = TRUE;
124✔
726
        } catch (\Exception $e) {
1✔
727
            error_log("poke Beanstalk exception " . $e->getMessage());
1✔
728
            $this->pheanstalk = NULL;
1✔
729
            $ret = FALSE;
1✔
730
        }
731

732
        return ($ret);
124✔
733
    }
734

735
    public function executePoke($userid, $data, $modtools)
736
    {
737
        # This kicks a user who is online at the moment with an outstanding long poll.
738
        Utils::filterResult($data);
1✔
739

740
        # We want to POST to notify.  We can speed this up using a persistent socket.
741
        $service_uri = "/publish?id=$userid";
1✔
742

743
        $topdata = array(
1✔
744
            'text' => $data,
1✔
745
            'channel' => $userid,
1✔
746
            'modtools' => $modtools,
1✔
747
            'id' => 1
1✔
748
        );
1✔
749

750
        $vars = json_encode($topdata);
1✔
751

752
        $header = "Host: " . CHAT_HOST . "\r\n";
1✔
753
        $header .= "User-Agent: Iznik Notify\r\n";
1✔
754
        $header .= "Content-Type: application/json\r\n";
1✔
755
        $header .= "Content-Length: " . strlen($vars) . "\r\n";
1✔
756
        $header .= "Connection: close\r\n\r\n";
1✔
757

758
        # Currently we don't do anything with these.  We used to send them to an nchan instance,
759
        # but that was no longer reliable and the clients don't use them.
760
        if (CHAT_HOST && FALSE) {
1✔
761
            try {
762
                #error_log("Connect to " . CHAT_HOST . " port " . CHAT_PORT);
UNCOV
763
                $fp = $this->fsockopen('ssl://' . CHAT_HOST, CHAT_PORT, $errno, $errstr, 2);
×
764

UNCOV
765
                if ($fp) {
×
UNCOV
766
                    if (!$this->fputs($fp, "POST $service_uri  HTTP/1.1\r\n")) {
×
767
                        # This can happen if the socket is broken.  Just close it ready for next time.
UNCOV
768
                        fclose($fp);
×
UNCOV
769
                        error_log("Failed to post");
×
770
                    } else {
UNCOV
771
                        fputs($fp, $header . $vars);
×
UNCOV
772
                        $server_response = fread($fp, 512);
×
773
                        fclose($fp);
×
774
                        #error_log("Rsp on $service_uri $server_response");
775
                    }
776
                }
UNCOV
777
            } catch (\Exception $e) {
×
778
                error_log("Failed to notify");
×
779
            }
780
        }
781
    }
782
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc