Skip to content

Commit 3bcb4d1

Browse files
committed
fix(notifications): dismiss comment mention notifications when viewed in activity sidebar
Add a `DELETE /notifications/dismiss/{id}` endpoint to the comments NotificationsController that marks the `comments/comment/mention` notification as processed without redirecting. When comments are loaded in the activity sidebar, the frontend calls this endpoint for any comment that mentions the current user, so the notification is cleared from the bell without requiring the user to navigate via the notification link. Fixes: nextcloud/activity#2531 AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent 8365464 commit 3bcb4d1

4 files changed

Lines changed: 148 additions & 0 deletions

File tree

apps/comments/appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
return [
1111
'routes' => [
1212
['name' => 'Notifications#view', 'url' => '/notifications/view/{id}', 'verb' => 'GET'],
13+
['name' => 'Notifications#dismiss', 'url' => '/notifications/dismiss/{id}', 'verb' => 'DELETE'],
1314
]
1415
];

apps/comments/lib/Controller/NotificationsController.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88

99
use OCP\AppFramework\Controller;
1010
use OCP\AppFramework\Http;
11+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
1112
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1213
use OCP\AppFramework\Http\Attribute\OpenAPI;
1314
use OCP\AppFramework\Http\Attribute\PublicPage;
15+
use OCP\AppFramework\Http\DataResponse;
1416
use OCP\AppFramework\Http\NotFoundResponse;
1517
use OCP\AppFramework\Http\RedirectResponse;
1618
use OCP\Comments\IComment;
@@ -89,6 +91,36 @@ public function view(string $id): RedirectResponse|NotFoundResponse {
8991
}
9092
}
9193

94+
/**
95+
* Dismiss the mention notification for a comment
96+
*
97+
* @param string $id ID of the comment
98+
*
99+
* @return DataResponse<Http::STATUS_OK, array{}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{}, array{}>
100+
*
101+
* 200: Notification dismissed successfully
102+
* 403: Not logged in
103+
* 404: Comment not found
104+
*/
105+
#[NoAdminRequired]
106+
public function dismiss(string $id): DataResponse {
107+
$currentUser = $this->userSession->getUser();
108+
if (!$currentUser instanceof IUser) {
109+
return new DataResponse([], Http::STATUS_FORBIDDEN);
110+
}
111+
112+
try {
113+
$comment = $this->commentsManager->get($id);
114+
if ($comment->getObjectType() !== 'files') {
115+
return new DataResponse([], Http::STATUS_NOT_FOUND);
116+
}
117+
$this->markProcessed($comment, $currentUser);
118+
return new DataResponse([]);
119+
} catch (\Exception $e) {
120+
return new DataResponse([], Http::STATUS_NOT_FOUND);
121+
}
122+
}
123+
92124
/**
93125
* Marks the notification about a comment as processed
94126
*/

apps/comments/src/comments-activity-tab.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import type { INode } from '@nextcloud/files'
77
import type { App } from 'vue'
88

9+
import { getCurrentUser } from '@nextcloud/auth'
10+
import axios from '@nextcloud/axios'
11+
import { generateUrl } from '@nextcloud/router'
912
import { createPinia } from 'pinia'
1013
import { createApp } from 'vue'
1114
import logger from './logger.ts'
@@ -50,6 +53,19 @@ export function registerCommentsPlugins() {
5053
},
5154
)
5255
logger.debug('Loaded comments', { node, comments })
56+
57+
// Mark mention notifications as read for comments that mention the current user
58+
const currentUser = getCurrentUser()
59+
if (currentUser) {
60+
for (const comment of comments) {
61+
const mentions = Object.values(comment.props?.mentions ?? {}) as { mentionType: string, mentionId: string }[]
62+
const isMentioned = comment.props?.id && mentions.some((m) => m.mentionType === 'user' && m.mentionId === currentUser.uid)
63+
if (isMentioned) {
64+
axios.delete(generateUrl('/apps/comments/notifications/dismiss/{id}', { id: comment.props.id }))
65+
.catch(() => {})
66+
}
67+
}
68+
}
5369
const { default: CommentView } = await import('./views/ActivityCommentEntry.vue')
5470

5571
return comments.map((comment) => ({

apps/comments/tests/Unit/Controller/NotificationsTest.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace OCA\Comments\Tests\Unit\Controller;
1111

1212
use OCA\Comments\Controller\NotificationsController;
13+
use OCP\AppFramework\Http\DataResponse;
1314
use OCP\AppFramework\Http\NotFoundResponse;
1415
use OCP\AppFramework\Http\RedirectResponse;
1516
use OCP\Comments\IComment;
@@ -162,6 +163,104 @@ public function testViewInvalidComment(): void {
162163
$this->assertInstanceOf(NotFoundResponse::class, $response);
163164
}
164165

166+
public function testDismissNotLoggedIn(): void {
167+
$this->session->expects($this->once())
168+
->method('getUser')
169+
->willReturn(null);
170+
171+
$this->commentsManager->expects($this->never())
172+
->method('get');
173+
$this->notificationManager->expects($this->never())
174+
->method('markProcessed');
175+
176+
$response = $this->notificationsController->dismiss('42');
177+
$this->assertInstanceOf(DataResponse::class, $response);
178+
$this->assertSame(403, $response->getStatus());
179+
}
180+
181+
public function testDismissSuccess(): void {
182+
$comment = $this->createMock(IComment::class);
183+
$comment->expects($this->any())
184+
->method('getObjectType')
185+
->willReturn('files');
186+
$comment->expects($this->any())
187+
->method('getId')
188+
->willReturn('1234');
189+
190+
$this->commentsManager->expects($this->once())
191+
->method('get')
192+
->with('42')
193+
->willReturn($comment);
194+
195+
$user = $this->createMock(IUser::class);
196+
$user->expects($this->any())
197+
->method('getUID')
198+
->willReturn('user');
199+
200+
$this->session->expects($this->once())
201+
->method('getUser')
202+
->willReturn($user);
203+
204+
$notification = $this->createMock(INotification::class);
205+
$notification->expects($this->any())
206+
->method($this->anything())
207+
->willReturn($notification);
208+
209+
$this->notificationManager->expects($this->once())
210+
->method('createNotification')
211+
->willReturn($notification);
212+
$this->notificationManager->expects($this->once())
213+
->method('markProcessed')
214+
->with($notification);
215+
216+
$response = $this->notificationsController->dismiss('42');
217+
$this->assertInstanceOf(DataResponse::class, $response);
218+
$this->assertSame(200, $response->getStatus());
219+
}
220+
221+
public function testDismissInvalidComment(): void {
222+
$this->commentsManager->expects($this->once())
223+
->method('get')
224+
->with('42')
225+
->willThrowException(new NotFoundException());
226+
227+
$user = $this->createMock(IUser::class);
228+
$this->session->expects($this->once())
229+
->method('getUser')
230+
->willReturn($user);
231+
232+
$this->notificationManager->expects($this->never())
233+
->method('markProcessed');
234+
235+
$response = $this->notificationsController->dismiss('42');
236+
$this->assertInstanceOf(DataResponse::class, $response);
237+
$this->assertSame(404, $response->getStatus());
238+
}
239+
240+
public function testDismissNonFileComment(): void {
241+
$comment = $this->createMock(IComment::class);
242+
$comment->expects($this->any())
243+
->method('getObjectType')
244+
->willReturn('calendar');
245+
246+
$this->commentsManager->expects($this->once())
247+
->method('get')
248+
->with('42')
249+
->willReturn($comment);
250+
251+
$user = $this->createMock(IUser::class);
252+
$this->session->expects($this->once())
253+
->method('getUser')
254+
->willReturn($user);
255+
256+
$this->notificationManager->expects($this->never())
257+
->method('markProcessed');
258+
259+
$response = $this->notificationsController->dismiss('42');
260+
$this->assertInstanceOf(DataResponse::class, $response);
261+
$this->assertSame(404, $response->getStatus());
262+
}
263+
165264
public function testViewNoFile(): void {
166265
$comment = $this->createMock(IComment::class);
167266
$comment->expects($this->any())

0 commit comments

Comments
 (0)