Skip to content

Commit 2e59c69

Browse files
committed
Introduce API endpoints to mark user notifications as read
1 parent 75d7d7a commit 2e59c69

File tree

14 files changed

+302
-56
lines changed

14 files changed

+302
-56
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Marks all user notifications as read.
3+
*
4+
* @author Olaf Braun
5+
* @copyright 2001-2025 WoltLab GmbH
6+
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7+
* @since 6.3
8+
* @woltlabExcludeBundle tiny
9+
*/
10+
11+
import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
12+
import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result";
13+
14+
export async function markAllUserNotificationsAsRead(): Promise<[]> {
15+
return fromInfallibleApiRequest(() => {
16+
return prepareRequest(`${window.WSC_RPC_API_URL}core/users/notifications/mark-all-as-read`).post().fetchAsJson();
17+
});
18+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Marks a user notification as read.
3+
*
4+
* @author Olaf Braun
5+
* @copyright 2001-2025 WoltLab GmbH
6+
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7+
* @since 6.3
8+
* @woltlabExcludeBundle tiny
9+
*/
10+
11+
import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result";
12+
import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
13+
14+
type Response = {
15+
unreadNotifications: number;
16+
};
17+
18+
export async function markUserNotificationAsRead(notificationId: number): Promise<Response> {
19+
return fromInfallibleApiRequest(() => {
20+
return prepareRequest(`${window.WSC_RPC_API_URL}core/users/notifications/${notificationId}/mark-as-read`)
21+
.post()
22+
.fetchAsJson();
23+
});
24+
}

ts/WoltLabSuite/Core/Controller/User/Notification/List.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
* @woltlabExcludeBundle tiny
99
*/
1010

11-
import { dboAction } from "WoltLabSuite/Core/Ajax";
1211
import { confirmationFactory } from "WoltLabSuite/Core/Component/Confirmation";
1312
import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar";
1413
import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
1514
import { getPhrase } from "WoltLabSuite/Core/Language";
15+
import { markAllUserNotificationsAsRead } from "WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead";
16+
import { markUserNotificationAsRead } from "WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead";
1617

1718
function initMarkAllAsRead(): void {
1819
document.querySelector(".jsMarkAllAsConfirmed")?.addEventListener(
@@ -29,7 +30,7 @@ async function markAllAsRead(): Promise<void> {
2930
return;
3031
}
3132

32-
await dboAction("markAllAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction").dispatch();
33+
await markAllUserNotificationsAsRead();
3334

3435
showDefaultSuccessSnackbar().addEventListener("snackbar:close", () => {
3536
window.location.reload();
@@ -46,9 +47,7 @@ function initMarkAsRead(): void {
4647
}
4748

4849
async function markAsRead(element: HTMLElement): Promise<void> {
49-
await dboAction("markAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction")
50-
.objectIds([parseInt(element.dataset.objectId!)])
51-
.dispatch();
50+
await markUserNotificationAsRead(parseInt(element.dataset.objectId!, 10));
5251

5352
element.querySelector(".notificationListItem__unread")?.remove();
5453
element.dataset.isRead = "true";

ts/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { registerProvider } from "../Manager";
1414
import * as Language from "../../../../Language";
1515
import { enableNotifications } from "../../../../Notification/Handler";
1616
import { registerServiceWorker, updateNotificationLastReadTime } from "../../../../Notification/ServiceWorker";
17+
import { markUserNotificationAsRead } from "WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead";
18+
import { markAllUserNotificationsAsRead } from "WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead";
1719

1820
let originalFavicon = "";
1921
function setFaviconCounter(counter: number): void {
@@ -277,16 +279,14 @@ class UserMenuDataNotification implements DesktopNotifications, UserMenuProvider
277279
}
278280

279281
async markAsRead(objectId: number): Promise<void> {
280-
const response = (await dboAction("markAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction")
281-
.objectIds([objectId])
282-
.dispatch()) as ResponseMarkAsRead;
282+
const { unreadNotifications } = await markUserNotificationAsRead(objectId);
283283
updateNotificationLastReadTime();
284284

285-
this.updateCounter(response.totalCount);
285+
this.updateCounter(unreadNotifications);
286286
}
287287

288288
async markAllAsRead(): Promise<void> {
289-
await dboAction("markAllAsConfirmed", "wcf\\data\\user\\notification\\UserNotificationAction").dispatch();
289+
await markAllUserNotificationsAsRead();
290290
updateNotificationLastReadTime();
291291

292292
this.updateCounter(0);

wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkAllUserNotificationsAsRead.js

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wcfsetup/install/files/js/WoltLabSuite/Core/Api/Users/Notifications/MarkUserNotificationAsRead.js

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wcfsetup/install/files/js/WoltLabSuite/Core/Controller/User/Notification/List.js

Lines changed: 3 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/Data/Notification.js

Lines changed: 4 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ static function (\wcf\event\endpoint\ControllerCollecting $event) {
226226
$event->register(new \wcf\system\endpoint\controller\core\users\groups\assignment\EnableAssignment());
227227
$event->register(new \wcf\system\endpoint\controller\core\users\groups\assignment\DisableAssignment());
228228
$event->register(new \wcf\system\endpoint\controller\core\users\groups\DeleteGroup());
229+
$event->register(new \wcf\system\endpoint\controller\core\users\notifications\MarkUserNotificationAsRead());
230+
$event->register(new \wcf\system\endpoint\controller\core\users\notifications\MarkAllUserNotificationsAsRead());
229231
$event->register(new \wcf\system\endpoint\controller\core\menus\DeleteMenu());
230232
$event->register(new \wcf\system\endpoint\controller\core\trophies\EnableTrophy());
231233
$event->register(new \wcf\system\endpoint\controller\core\trophies\DisableTrophy());
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
namespace wcf\command\user\notification;
4+
5+
use wcf\event\user\notification\AllUserNotificationsMarkAsRead;
6+
use wcf\system\database\util\PreparedStatementConditionBuilder;
7+
use wcf\system\event\EventHandler;
8+
use wcf\system\user\storage\UserStorageHandler;
9+
use wcf\system\WCF;
10+
11+
/**
12+
* Marks all user notifications as read.
13+
*
14+
* @author Olaf Braun
15+
* @copyright 2001-2025 WoltLab GmbH
16+
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
17+
* @since 6.3
18+
*/
19+
final class MarkAllUserNotificationsAsRead
20+
{
21+
public function __construct(
22+
private readonly int $userID,
23+
) {}
24+
25+
public function __invoke(): void
26+
{
27+
// Step 1) Find the IDs of the unread notifications.
28+
// This is done in a separate step, because this allows the UPDATE query to
29+
// leverage fine-grained locking of exact rows based off the PRIMARY KEY.
30+
// Simply updating all notifications belonging to a specific user will need
31+
// to prevent concurrent threads from inserting new notifications for proper
32+
// consistency, possibly leading to deadlocks.
33+
$notificationIDs = $this->getUnreadNotificationIDs();
34+
35+
if ($notificationIDs !== []) {
36+
// Step 2) Mark the notifications as read.
37+
$this->markNotificationsAsRead($notificationIDs);
38+
}
39+
40+
$this->clearCache();
41+
42+
$event = new AllUserNotificationsMarkAsRead($this->userID);
43+
EventHandler::getInstance()->fire($event);
44+
}
45+
46+
/**
47+
* @return list<int>
48+
*/
49+
private function getUnreadNotificationIDs(): array
50+
{
51+
$sql = "SELECT notificationID
52+
FROM wcf1_user_notification
53+
WHERE userID = ?
54+
AND confirmTime = ?
55+
AND time < ?";
56+
$statement = WCF::getDB()->prepare($sql);
57+
$statement->execute([
58+
$this->userID,
59+
0,
60+
TIME_NOW,
61+
]);
62+
63+
return $statement->fetchAll(\PDO::FETCH_COLUMN);
64+
}
65+
66+
/**
67+
* @param list<int> $notificationIDs
68+
*/
69+
private function markNotificationsAsRead(array $notificationIDs): void
70+
{
71+
$condition = new PreparedStatementConditionBuilder();
72+
$condition->add('notificationID IN (?)', [$notificationIDs]);
73+
74+
$sql = "UPDATE wcf1_user_notification
75+
SET confirmTime = ?
76+
{$condition}";
77+
$statement = WCF::getDB()->prepare($sql);
78+
$statement->execute(\array_merge([TIME_NOW], $condition->getParameters()));
79+
}
80+
81+
private function clearCache(): void
82+
{
83+
UserStorageHandler::getInstance()->reset([$this->userID], 'userNotificationCount');
84+
}
85+
}

0 commit comments

Comments
 (0)