Skip to content

Commit b3a130b

Browse files
committed
Add user option to globally disable web push prompts
1 parent 4d98463 commit b3a130b

File tree

10 files changed

+297
-1
lines changed

10 files changed

+297
-1
lines changed

config/wpn_ucp.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ phpbb_webpushnotifications_ucp_push_subscribe_controller:
1313
phpbb_webpushnotifications_ucp_push_unsubscribe_controller:
1414
path: /push/unsubscribe
1515
defaults: { _controller: phpbb.wpn.ucp.controller.webpush:unsubscribe }
16+
17+
phpbb_webpushnotifications_ucp_push_toggle_popup_controller:
18+
path: /push/toggle-popup
19+
defaults: { _controller: phpbb.wpn.ucp.controller.webpush:toggle_popup }

language/en/webpushnotifications_module_ucp.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,8 @@
5353
'NOTIFY_WEBPUSH_POPUP_MESSAGE' => 'We would like to send you browser notifications for replies, private messages, and relevant forum activity. Optional — you can manage these settings at any time.',
5454
'NOTIFY_WEBPUSH_POPUP_ALLOW' => 'Allow',
5555
'NOTIFY_WEBPUSH_POPUP_DENY' => 'Deny',
56+
'NOTIFY_WEBPUSH_POPUP_DISABLE_PROMPTS' => 'Disable web push notification prompts',
57+
'NOTIFY_WEBPUSH_POPUP_DISABLE' => 'Disable popup prompt',
58+
'NOTIFY_WEBPUSH_POPUP_ENABLE' => 'Enable popup prompt',
59+
'NOTIFY_WEBPUSH_POPUP_PREFERENCE_EXPLAIN' => 'Turn this on to stop us from asking you to enable web push notifications on any of your devices. If you disable web push notification prompts, we won’t be able to alert you if you ever become unsubscribed.',
5660
]);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
/**
3+
*
4+
* phpBB Browser Push Notifications. An extension for the phpBB Forum Software package.
5+
*
6+
* @copyright (c) 2025, phpBB Limited <https://www.phpbb.com>
7+
* @license GNU General Public License, version 2 (GPL-2.0)
8+
*
9+
*/
10+
11+
namespace phpbb\webpushnotifications\migrations;
12+
13+
use phpbb\db\migration\migration;
14+
15+
class add_user_popup_preference extends migration
16+
{
17+
public function effectively_installed()
18+
{
19+
return $this->db_tools->sql_column_exists($this->table_prefix . 'users', 'user_wpn_popup_disabled');
20+
}
21+
22+
public static function depends_on()
23+
{
24+
return ['\phpbb\webpushnotifications\migrations\add_popup_prompt'];
25+
}
26+
27+
public function update_schema()
28+
{
29+
return [
30+
'add_columns' => [
31+
$this->table_prefix . 'users' => [
32+
'user_wpn_popup_disabled' => ['BOOL', 0],
33+
],
34+
],
35+
];
36+
}
37+
38+
public function revert_schema()
39+
{
40+
return [
41+
'drop_columns' => [
42+
$this->table_prefix . 'users' => [
43+
'user_wpn_popup_disabled',
44+
],
45+
],
46+
];
47+
}
48+
}

notification/method/webpush.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,11 +390,13 @@ public function get_ucp_template_data(helper $controller_helper, form_helper $fo
390390
'NOTIFICATIONS_WEBPUSH_ENABLE' => ($this->config['load_notifications'] && $this->config['allow_board_notifications'] && $this->config['wpn_webpush_dropdown_subscribe']) || stripos($this->user->page['page'], 'notification_options'),
391391
'U_WEBPUSH_SUBSCRIBE' => $controller_helper->route('phpbb_webpushnotifications_ucp_push_subscribe_controller'),
392392
'U_WEBPUSH_UNSUBSCRIBE' => $controller_helper->route('phpbb_webpushnotifications_ucp_push_unsubscribe_controller'),
393+
'U_WEBPUSH_TOGGLE_POPUP' => $controller_helper->route('phpbb_webpushnotifications_ucp_push_toggle_popup_controller'),
393394
'VAPID_PUBLIC_KEY' => $this->config['wpn_webpush_vapid_public'],
394395
'U_WEBPUSH_WORKER_URL' => $controller_helper->route('phpbb_webpushnotifications_ucp_push_worker_controller'),
395396
'SUBSCRIPTIONS' => $subscriptions,
396397
'WEBPUSH_FORM_TOKENS' => $form_helper->get_form_tokens(\phpbb\webpushnotifications\ucp\controller\webpush::FORM_TOKEN_UCP),
397-
'S_WEBPUSH_POPUP_PROMPT' => $this->config['wpn_webpush_popup_prompt'] && $this->user->id() != ANONYMOUS && $this->user->data['user_type'] != USER_IGNORE,
398+
'S_WEBPUSH_POPUP_PROMPT' => $this->config['wpn_webpush_popup_prompt'] && $this->user->id() != ANONYMOUS && $this->user->data['user_type'] != USER_IGNORE && !($this->user->data['user_wpn_popup_disabled'] ?? 0),
399+
'S_WEBPUSH_POPUP_DISABLED' => $this->user->data['user_wpn_popup_disabled'] ?? 0,
398400
];
399401
}
400402

styles/all/template/ucp_notifications_webpush.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
serviceWorkerUrl: '{{ U_WEBPUSH_WORKER_URL }}',
44
subscribeUrl: '{{ U_WEBPUSH_SUBSCRIBE }}',
55
unsubscribeUrl: '{{ U_WEBPUSH_UNSUBSCRIBE }}',
6+
togglePopupUrl: '{{ U_WEBPUSH_TOGGLE_POPUP }}',
67
ajaxErrorTitle: '{{ lang_js('AJAX_ERROR_TITLE') }}',
78
vapidPublicKey: '{{ VAPID_PUBLIC_KEY }}',
89
formTokens: {

styles/all/template/webpush.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ function PhpbbWebpush() {
3333
/** @type {HTMLElement} Unsubscribe button */
3434
let unsubscribeButton;
3535

36+
/** @type {HTMLElement} Toggle popup button */
37+
let togglePopupButton;
38+
39+
/** @type {string} URL to toggle popup prompt preference */
40+
let togglePopupUrl = '';
41+
3642
/** @type {function} Escape key handler for popup */
3743
let popupEscapeHandler;
3844

@@ -44,13 +50,20 @@ function PhpbbWebpush() {
4450
serviceWorkerUrl = options.serviceWorkerUrl;
4551
subscribeUrl = options.subscribeUrl;
4652
unsubscribeUrl = options.unsubscribeUrl;
53+
togglePopupUrl = options.togglePopupUrl;
4754
this.formTokens = options.formTokens;
4855
subscriptions = options.subscriptions;
4956
ajaxErrorTitle = options.ajaxErrorTitle;
5057
vapidPublicKey = options.vapidPublicKey;
5158

5259
subscribeButton = document.querySelector('#subscribe_webpush');
5360
unsubscribeButton = document.querySelector('#unsubscribe_webpush');
61+
togglePopupButton = document.querySelector('#toggle_popup_prompt');
62+
63+
// Set up toggle popup button handler if it exists (on UCP settings page)
64+
if (togglePopupButton) {
65+
togglePopupButton.addEventListener('click', togglePopupHandler);
66+
}
5467

5568
// Service workers are only supported in secure context
5669
if (window.isSecureContext !== true) {
@@ -352,6 +365,55 @@ function PhpbbWebpush() {
352365
});
353366
}
354367

368+
/**
369+
* Handler for toggle popup prompt button
370+
*
371+
* @param {Object} event Toggle button push event
372+
*/
373+
function togglePopupHandler(event) {
374+
event.preventDefault();
375+
376+
const loadingIndicator = phpbb.loadingIndicator();
377+
const formData = new FormData();
378+
formData.append('form_token', phpbb.webpush.formTokens.formToken);
379+
formData.append('creation_time', phpbb.webpush.formTokens.creationTime.toString());
380+
381+
fetch(togglePopupUrl, {
382+
method: 'POST',
383+
headers: {
384+
'X-Requested-With': 'XMLHttpRequest',
385+
},
386+
body: formData,
387+
})
388+
.then(response => response.json())
389+
.then(data => {
390+
loadingIndicator.fadeOut(phpbb.alertTime);
391+
if (data.success) {
392+
// Update toggle icon based on new state
393+
const button = document.getElementById('toggle_popup_prompt');
394+
if (button) {
395+
const icon = button.querySelector('i');
396+
if (icon) {
397+
if (data.disabled) {
398+
icon.classList.remove('fa-toggle-off');
399+
icon.classList.add('fa-toggle-on');
400+
} else {
401+
icon.classList.remove('fa-toggle-on');
402+
icon.classList.add('fa-toggle-off');
403+
}
404+
}
405+
}
406+
if ('form_tokens' in data) {
407+
updateFormTokens(data.form_tokens);
408+
}
409+
}
410+
})
411+
.catch(error => {
412+
loadingIndicator.fadeOut(phpbb.alertTime);
413+
phpbb.alert(ajaxErrorTitle, error);
414+
});
415+
}
416+
355417
/**
356418
* Handle subscribe response
357419
*

styles/prosilver/template/event/ucp_notifications_content_before.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@
1010
<br><span>{{ lang('NOTIFY_WEBPUSH_ENABLE_EXPLAIN') }}</span>
1111
</dd>
1212
</dl>
13+
<dl>
14+
<dt><label for="toggle_popup_prompt">{{ lang('NOTIFY_WEBPUSH_POPUP_DISABLE_PROMPTS') ~ lang('COLON') }}</label></dt>
15+
<dd>
16+
<button id="toggle_popup_prompt" type="button" name="toggle_popup_prompt" class="wpn-toggle-button" aria-label="{{ lang(S_WEBPUSH_POPUP_DISABLED ? 'NOTIFY_WEBPUSH_POPUP_ENABLE' : 'NOTIFY_WEBPUSH_POPUP_DISABLE') }}">
17+
<i class="icon icon-lg fa-fw fa-toggle-{% if S_WEBPUSH_POPUP_DISABLED %}on{% else %}off{% endif %}" aria-hidden="true"></i>
18+
</button>
19+
<br><span>{{ lang('NOTIFY_WEBPUSH_POPUP_PREFERENCE_EXPLAIN') }}</span>
20+
</dd>
21+
</dl>
1322
</fieldset>
1423
</div>
1524
</div>

tests/controller/controller_webpush_test.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,85 @@ public function test_sub_unsubscribe_success()
420420
$this->assertEmpty($this->get_subscription_data());
421421
}
422422

423+
public function test_toggle_popup_enable_to_disable()
424+
{
425+
$this->form_helper->method('check_form_tokens')->willReturn(true);
426+
$this->request->method('is_ajax')->willReturn(true);
427+
$this->user->data['user_id'] = 2;
428+
$this->user->data['is_bot'] = false;
429+
$this->user->data['user_type'] = USER_NORMAL;
430+
$this->user->data['user_wpn_popup_disabled'] = 0;
431+
432+
$response = $this->controller->toggle_popup();
433+
434+
$this->assertInstanceOf(JsonResponse::class, $response);
435+
$response_data = json_decode($response->getContent(), true);
436+
437+
$this->assertTrue($response_data['success']);
438+
$this->assertTrue($response_data['disabled']);
439+
$this->assertEquals(1, $this->get_user_popup_preference(2));
440+
}
441+
442+
public function test_toggle_popup_disable_to_enable()
443+
{
444+
$this->form_helper->method('check_form_tokens')->willReturn(true);
445+
$this->request->method('is_ajax')->willReturn(true);
446+
$this->user->data['user_id'] = 2;
447+
$this->user->data['is_bot'] = false;
448+
$this->user->data['user_type'] = USER_NORMAL;
449+
$this->user->data['user_wpn_popup_disabled'] = 1;
450+
451+
// Set initial state
452+
$sql = 'UPDATE phpbb_users
453+
SET user_wpn_popup_disabled = 1
454+
WHERE user_id = 2';
455+
$this->db->sql_query($sql);
456+
457+
$response = $this->controller->toggle_popup();
458+
459+
$this->assertInstanceOf(JsonResponse::class, $response);
460+
$response_data = json_decode($response->getContent(), true);
461+
462+
$this->assertTrue($response_data['success']);
463+
$this->assertFalse($response_data['disabled']);
464+
$this->assertEquals(0, $this->get_user_popup_preference(2));
465+
}
466+
467+
public function test_toggle_popup_invalid_form_token()
468+
{
469+
$this->form_helper->method('check_form_tokens')->willReturn(false);
470+
471+
$this->expectException(http_exception::class);
472+
$this->expectExceptionMessage('FORM_INVALID');
473+
474+
$this->controller->toggle_popup();
475+
}
476+
477+
public function test_toggle_popup_not_ajax()
478+
{
479+
$this->form_helper->method('check_form_tokens')->willReturn(true);
480+
$this->request->method('is_ajax')->willReturn(false);
481+
482+
$this->expectException(http_exception::class);
483+
$this->expectExceptionMessage('NO_AUTH_OPERATION');
484+
485+
$this->controller->toggle_popup();
486+
}
487+
488+
public function test_toggle_popup_anonymous_user()
489+
{
490+
$this->form_helper->method('check_form_tokens')->willReturn(true);
491+
$this->request->method('is_ajax')->willReturn(true);
492+
$this->user->data['user_id'] = ANONYMOUS;
493+
$this->user->data['is_bot'] = false;
494+
$this->user->data['user_type'] = USER_NORMAL;
495+
496+
$this->expectException(http_exception::class);
497+
$this->expectExceptionMessage('NO_AUTH_OPERATION');
498+
499+
$this->controller->toggle_popup();
500+
}
501+
423502
private function get_subscription_data()
424503
{
425504
$sql = 'SELECT *
@@ -430,4 +509,15 @@ private function get_subscription_data()
430509
$this->db->sql_freeresult($result);
431510
return $row;
432511
}
512+
513+
private function get_user_popup_preference($user_id)
514+
{
515+
$sql = 'SELECT user_wpn_popup_disabled
516+
FROM phpbb_users
517+
WHERE user_id = ' . (int) $user_id;
518+
$result = $this->db->sql_query($sql);
519+
$value = (int) $this->db->sql_fetchfield('user_wpn_popup_disabled');
520+
$this->db->sql_freeresult($result);
521+
return $value;
522+
}
433523
}

tests/functional/functional_test.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,58 @@ public function test_popup_prompt()
162162
$this->assertContainsLang('NOTIFY_WEBPUSH_POPUP_DENY', $crawler->filter('#wpn_popup_deny')->text());
163163
}
164164

165+
public function test_popup_preference_toggle()
166+
{
167+
$this->login();
168+
$this->admin_login();
169+
170+
$this->add_lang_ext('phpbb/webpushnotifications', 'webpushnotifications_module_ucp');
171+
172+
$this->set_acp_option('wpn_webpush_popup_prompt', 1);
173+
174+
// Go to UCP notification settings
175+
$crawler = self::request('GET', 'ucp.php?i=ucp_notifications&mode=notification_options');
176+
177+
// Assert toggle button is present
178+
$this->assertCount(1, $crawler->filter('#toggle_popup_prompt'));
179+
$this->assertContainsLang('NOTIFY_WEBPUSH_POPUP_DISABLE_PROMPTS', $crawler->filter('label[for="toggle_popup_prompt"]')->text());
180+
181+
// Assert toggle is initially off (prompts enabled)
182+
$toggle_icon = $crawler->filter('#toggle_popup_prompt i');
183+
$this->assertCount(1, $toggle_icon);
184+
$this->assertTrue($toggle_icon->attr('class') !== null && strpos($toggle_icon->attr('class'), 'fa-toggle-off') !== false);
185+
186+
// After user disables popup (in reality this would be via AJAX, but we test the state)
187+
// Set user preference to disabled via DB
188+
$db = $this->get_db();
189+
$sql = 'UPDATE phpbb_users
190+
SET user_wpn_popup_disabled = 1
191+
WHERE user_id = 2';
192+
$db->sql_query($sql);
193+
194+
// Reload page
195+
$crawler = self::request('GET', 'ucp.php?i=ucp_notifications&mode=notification_options');
196+
197+
// Assert toggle is now on (prompts disabled)
198+
$toggle_icon = $crawler->filter('#toggle_popup_prompt i');
199+
$this->assertCount(1, $toggle_icon);
200+
$this->assertTrue($toggle_icon->attr('class') !== null && strpos($toggle_icon->attr('class'), 'fa-toggle-on') !== false);
201+
202+
// Assert popup is not shown on index when user has disabled it
203+
$crawler = self::request('GET', 'index.php');
204+
$this->assertCount(0, $crawler->filter('#wpn_popup_prompt'));
205+
206+
// Re-enable popup preference
207+
$sql = 'UPDATE phpbb_users
208+
SET user_wpn_popup_disabled = 0
209+
WHERE user_id = 2';
210+
$db->sql_query($sql);
211+
212+
// Assert popup is shown again
213+
$crawler = self::request('GET', 'index.php');
214+
$this->assertCount(1, $crawler->filter('#wpn_popup_prompt'));
215+
}
216+
165217
protected function set_acp_option($option, $value)
166218
{
167219
$crawler = self::request('GET', 'adm/index.php?i=-phpbb-webpushnotifications-acp-wpn_acp_module&mode=webpush&sid=' . $this->sid);

ucp/controller/webpush.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,30 @@ public function unsubscribe(symfony_request $symfony_request): JsonResponse
352352
]);
353353
}
354354

355+
/**
356+
* Handle toggle popup prompt requests
357+
*
358+
* @return JsonResponse
359+
*/
360+
public function toggle_popup(): JsonResponse
361+
{
362+
$this->check_subscribe_requests();
363+
364+
// Toggle the user preference
365+
$new_value = !$this->user->data['user_wpn_popup_disabled'];
366+
367+
$sql = 'UPDATE ' . USERS_TABLE . '
368+
SET user_wpn_popup_disabled = ' . (int) $new_value . '
369+
WHERE user_id = ' . (int) $this->user->id();
370+
$this->db->sql_query($sql);
371+
372+
return new JsonResponse([
373+
'success' => true,
374+
'disabled' => $new_value,
375+
'form_tokens' => $this->form_helper->get_form_tokens(self::FORM_TOKEN_UCP),
376+
]);
377+
}
378+
355379
/**
356380
* Takes an avatar string (usually in full html format already) and extracts the url.
357381
* If the avatar url is a relative path, it's converted to an absolute path.

0 commit comments

Comments
 (0)