Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions notification/method/webpush.php
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,47 @@ protected function notify_using_webpush(): void
*/
public function mark_notifications($notification_type_id, $item_id, $user_id, $time = false, $mark_read = true)
{
// Send dismiss push messages BEFORE deleting to close the browser notifications
// This is called by the notification manager when phpBB marks notifications as read
// (e.g., viewing a PM, viewing a topic, clicking "mark all read", etc.)
if ($notification_type_id !== false && $item_id !== false && $user_id !== false)
{
// When item_id and user_id are specific, send dismiss for each notification
// Arrays are typically same-length parallel arrays or single notification type with specific item
$type_ids = is_array($notification_type_id) ? $notification_type_id : [$notification_type_id];
$item_ids = is_array($item_id) ? $item_id : [$item_id];
$user_ids = is_array($user_id) ? $user_id : [$user_id];

// Most common case: single notification (single type, item, user)
if (count($type_ids) === 1 && count($item_ids) === 1 && count($user_ids) === 1)
{
$this->dismiss_using_webpush($type_ids[0], $item_ids[0], $user_ids[0]);
}
// Parallel arrays case: matching length arrays
else if (count($type_ids) === count($item_ids) && count($item_ids) === count($user_ids))
{
for ($i = 0, $iMax = count($type_ids); $i < $iMax; $i++)
{
$this->dismiss_using_webpush($type_ids[$i], $item_ids[$i], $user_ids[$i]);
}
}
// Mixed case: iterate combinations (rare but handle it)
else
{
foreach ($type_ids as $type)
{
foreach ($item_ids as $iid)
{
foreach ($user_ids as $uid)
{
$this->dismiss_using_webpush($type, $iid, $uid);
}
}
}
}
}

// Delete the notifications from our table
$sql = 'DELETE FROM ' . $this->notification_webpush_table . '
WHERE ' . ($notification_type_id !== false ? $this->db->sql_in_set('notification_type_id', is_array($notification_type_id) ? $notification_type_id : [$notification_type_id]) : '1=1') .
($user_id !== false ? ' AND ' . $this->db->sql_in_set('user_id', $user_id) : '') .
Expand All @@ -322,6 +363,24 @@ public function mark_notifications($notification_type_id, $item_id, $user_id, $t
*/
public function mark_notifications_by_parent($notification_type_id, $item_parent_id, $user_id, $time = false, $mark_read = true)
{
// Send dismiss push messages BEFORE deleting
// Query needed because service worker uses item_id (not item_parent_id) to match notification tags
if ($notification_type_id !== false && $user_id !== false && $item_parent_id !== false)
{
$sql = 'SELECT notification_type_id, item_id, user_id
FROM ' . $this->notification_webpush_table . '
WHERE ' . $this->db->sql_in_set('notification_type_id', is_array($notification_type_id) ? $notification_type_id : [$notification_type_id]) .
' AND ' . $this->db->sql_in_set('user_id', is_array($user_id) ? $user_id : [$user_id]) .
' AND ' . $this->db->sql_in_set('item_parent_id', is_array($item_parent_id) ? $item_parent_id : [$item_parent_id], false, true);
$result = $this->db->sql_query($sql);
while ($row = $this->db->sql_fetchrow($result))
{
$this->dismiss_using_webpush($row['notification_type_id'], $row['item_id'], $row['user_id']);
}
$this->db->sql_freeresult($result);
}

// Delete the notifications from our table
$sql = 'DELETE FROM ' . $this->notification_webpush_table . '
WHERE ' . ($notification_type_id !== false ? $this->db->sql_in_set('notification_type_id', is_array($notification_type_id) ? $notification_type_id : [$notification_type_id]) : '1=1') .
($user_id !== false ? ' AND ' . $this->db->sql_in_set('user_id', $user_id) : '') .
Expand Down Expand Up @@ -493,4 +552,75 @@ protected function set_endpoint_padding(\Minishlink\WebPush\WebPush $web_push, s
}
}
}

/**
* Send dismiss message via Web Push to close a browser notification
*
* @param int $notification_type_id Notification type ID
* @param int $item_id Item ID
* @param int $user_id User ID
* @return void
*/
protected function dismiss_using_webpush(int $notification_type_id, int $item_id, int $user_id): void
{
// Get user subscriptions
$user_subscription_map = $this->get_user_subscription_map([$user_id]);
$user_subscriptions = $user_subscription_map[$user_id] ?? [];

if (empty($user_subscriptions))
{
return;
}

$auth = [
'VAPID' => [
'subject' => generate_board_url(false),
'publicKey' => $this->config['wpn_webpush_vapid_public'],
'privateKey' => $this->config['wpn_webpush_vapid_private'],
],
];

$web_push = new \Minishlink\WebPush\WebPush($auth);

// Create dismiss message
$data = [
'action' => 'dismiss',
'notifications' => [[
'type_id' => $notification_type_id,
'item_id' => $item_id,
]],
];
$json_data = json_encode($data);

// Send dismiss message to all user's subscriptions
foreach ($user_subscriptions as $subscription)
{
try
{
$this->set_endpoint_padding($web_push, $subscription['endpoint']);
$push_subscription = Subscription::create([
'endpoint' => $subscription['endpoint'],
'keys' => [
'p256dh' => $subscription['p256dh'],
'auth' => $subscription['auth'],
],
]);
$web_push->queueNotification($push_subscription, $json_data);
}
catch (\ErrorException $exception)
{
// Ignore - dismiss is best-effort
}
}

// Flush and ignore any errors - dismiss messages are best-effort
try
{
$web_push->flush();
}
catch (\ErrorException $exception)
{
// Ignore errors
}
}
}
55 changes: 44 additions & 11 deletions styles/all/template/push_worker.js.twig
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,27 @@ self.addEventListener('push', event => {
return;
}

let itemId = 0;
let typeId = 0;
let userId = 0;
let notificationVersion = 0;
let pushToken = '';
let pushData;
try {
const notificationData = event.data.json();
itemId = notificationData.item_id;
typeId = notificationData.type_id;
userId = notificationData.user_id;
notificationVersion = parseInt(notificationData.version, 10);
pushToken = notificationData.token;
pushData = event.data.json();
} catch {
event.waitUntil(self.registration.showNotification(event.data.text()));
return;
}

// Handle dismiss action
if (pushData.action === 'dismiss') {
event.waitUntil(handleDismissNotifications(pushData.notifications));
return;
}

// Handle regular notification display
let itemId = pushData.item_id || 0;
let typeId = pushData.type_id || 0;
let userId = pushData.user_id || 0;
let notificationVersion = parseInt(pushData.version, 10) || 0;
let pushToken = pushData.token || '';

event.waitUntil((async () => {
const getNotificationUrl = '{{ U_WEBPUSH_GET_NOTIFICATION }}';
const assetsVersion = parseInt('{{ ASSETS_VERSION }}', 10);
Expand Down Expand Up @@ -65,10 +69,12 @@ self.addEventListener('push', event => {
const responseData = await response.json();

const responseBody = responseData.title + '\n' + responseData.text;
const notificationTag = typeId + '_' + itemId;
const options = {
body: responseBody,
data: responseData,
icon: responseData.avatar.src,
tag: notificationTag,
};

await self.registration.showNotification(responseData.heading, options);
Expand All @@ -87,3 +93,30 @@ self.addEventListener('notificationclick', event => {
event.waitUntil(self.clients.openWindow(event.notification.data.url));
}
});

/**
* Handle dismiss notifications pushed from the server
*
* @param {Array} notifications Array of notifications to dismiss
* @returns {Promise<void>}
*/
async function handleDismissNotifications(notifications) {
if (!notifications || !Array.isArray(notifications) || notifications.length === 0) {
return;
}

try {
// Close each notification by its tag
for (const dismissed of notifications) {
const tag = dismissed.type_id + '_' + dismissed.item_id;

// Get and close notifications with this specific tag
const matchingNotifications = await self.registration.getNotifications({ tag: tag });
for (const notification of matchingNotifications) {
notification.close();
}
}
} catch (e) {
console.error('Error dismissing notifications:', e);
}
}