Skip to content

Commit 6b1affb

Browse files
committed
fix(comments): dismiss mention notifications and clear unread badge when viewed in activity sidebar
Backport of #60617 for stable31. When comments are loaded via the Activity sidebar integration, call markCommentsAsRead() so the file-row unread comment bubble clears after viewing. Also add a DELETE /notifications/{id} endpoint and call it for each comment that mentions the current user so the notification bell clears without navigating 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 b70b80a commit 6b1affb

443 files changed

Lines changed: 6491 additions & 2484 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/comments/appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@
88
return [
99
'routes' => [
1010
['name' => 'Notifications#view', 'url' => '/notifications/view/{id}', 'verb' => 'GET'],
11+
['name' => 'Notifications#dismiss', 'url' => '/notifications/{id}', 'verb' => 'DELETE'],
1112
]
1213
];

apps/comments/lib/Controller/NotificationsController.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1111
use OCP\AppFramework\Http\Attribute\OpenAPI;
1212
use OCP\AppFramework\Http\Attribute\PublicPage;
13+
use OCP\AppFramework\Http\DataResponse;
1314
use OCP\AppFramework\Http\NotFoundResponse;
1415
use OCP\AppFramework\Http\RedirectResponse;
1516
use OCP\Comments\IComment;
@@ -91,6 +92,37 @@ public function view(string $id): RedirectResponse|NotFoundResponse {
9192
}
9293
}
9394

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

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
import { getCurrentUser } from '@nextcloud/auth'
6+
import axios from '@nextcloud/axios'
57
import moment from '@nextcloud/moment'
8+
import { generateUrl } from '@nextcloud/router'
69
import Vue, { type ComponentPublicInstance } from 'vue'
710
import logger from './logger.js'
811
import { getComments } from './services/GetComments.js'
12+
import { markCommentsAsRead } from './services/ReadComments.js'
913

1014
import { PiniaVuePlugin, createPinia } from 'pinia'
1115

@@ -49,6 +53,35 @@ export function registerCommentsPlugins() {
4953
window.OCA.Activity.registerSidebarEntries(async ({ fileInfo, limit, offset }) => {
5054
const { data: comments } = await getComments({ resourceType: 'files', resourceId: fileInfo.id }, { limit, offset })
5155
logger.debug('Loaded comments', { fileInfo, comments })
56+
57+
// Optimistically clear the unread bubble in the file list immediately
58+
// (without waiting for the PROPPATCH to complete), so the UI updates
59+
// without requiring a page refresh.
60+
// fileInfo.node is the underlying @nextcloud/files Node set by the Files sidebar.
61+
// Optimistically clear the unread bubble immediately via the global event bus
62+
// (window._nc_event_bus) so the UI updates without a page refresh.
63+
// fileInfo.node is the underlying @nextcloud/files Node set by the Files sidebar.
64+
const node = fileInfo.node
65+
if (node) {
66+
node.attributes['comments-unread'] = 0
67+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
68+
;(window as any)._nc_event_bus?.emit('files:node:updated', node)
69+
}
70+
markCommentsAsRead('files', fileInfo.id, new Date()).catch(() => {})
71+
72+
// Mark mention notifications as read for comments that mention the current user
73+
const currentUser = getCurrentUser()
74+
if (currentUser) {
75+
for (const comment of comments) {
76+
const mentions = Object.values(comment.props?.mentions ?? {}) as { mentionType: string, mentionId: string }[]
77+
const isMentioned = comment.props?.id && mentions.some((m) => m.mentionType === 'user' && m.mentionId === currentUser.uid)
78+
if (isMentioned) {
79+
axios.delete(generateUrl('/apps/comments/notifications/{id}', { id: comment.props.id }))
80+
.catch(() => {})
81+
}
82+
}
83+
}
84+
5285
const { default: CommentView } = await import('./views/ActivityCommentEntry.vue')
5386
// @ts-expect-error Types are broken for Vue2
5487
const CommentsViewObject = Vue.extend(CommentView)

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

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

1010
use OCA\Comments\Controller\NotificationsController;
11+
use OCP\AppFramework\Http\DataResponse;
1112
use OCP\AppFramework\Http\NotFoundResponse;
1213
use OCP\AppFramework\Http\RedirectResponse;
1314
use OCP\Comments\IComment;
@@ -223,4 +224,102 @@ public function testViewNoFile(): void {
223224
$response = $this->notificationsController->view('42');
224225
$this->assertInstanceOf(NotFoundResponse::class, $response);
225226
}
227+
228+
public function testDismissNotLoggedIn(): void {
229+
$this->session->expects($this->once())
230+
->method('getUser')
231+
->willReturn(null);
232+
233+
$this->commentsManager->expects($this->never())
234+
->method('get');
235+
$this->notificationManager->expects($this->never())
236+
->method('markProcessed');
237+
238+
$response = $this->notificationsController->dismiss('42');
239+
$this->assertInstanceOf(DataResponse::class, $response);
240+
$this->assertSame(403, $response->getStatus());
241+
}
242+
243+
public function testDismissSuccess(): void {
244+
$comment = $this->createMock(IComment::class);
245+
$comment->expects($this->any())
246+
->method('getObjectType')
247+
->willReturn('files');
248+
$comment->expects($this->any())
249+
->method('getId')
250+
->willReturn('1234');
251+
252+
$this->commentsManager->expects($this->once())
253+
->method('get')
254+
->with('42')
255+
->willReturn($comment);
256+
257+
$user = $this->createMock(IUser::class);
258+
$user->expects($this->any())
259+
->method('getUID')
260+
->willReturn('user');
261+
262+
$this->session->expects($this->once())
263+
->method('getUser')
264+
->willReturn($user);
265+
266+
$notification = $this->createMock(INotification::class);
267+
$notification->expects($this->any())
268+
->method($this->anything())
269+
->willReturn($notification);
270+
271+
$this->notificationManager->expects($this->once())
272+
->method('createNotification')
273+
->willReturn($notification);
274+
$this->notificationManager->expects($this->once())
275+
->method('markProcessed')
276+
->with($notification);
277+
278+
$response = $this->notificationsController->dismiss('42');
279+
$this->assertInstanceOf(DataResponse::class, $response);
280+
$this->assertSame(200, $response->getStatus());
281+
}
282+
283+
public function testDismissInvalidComment(): void {
284+
$this->commentsManager->expects($this->once())
285+
->method('get')
286+
->with('42')
287+
->will($this->throwException(new NotFoundException()));
288+
289+
$user = $this->createMock(IUser::class);
290+
$this->session->expects($this->once())
291+
->method('getUser')
292+
->willReturn($user);
293+
294+
$this->notificationManager->expects($this->never())
295+
->method('markProcessed');
296+
297+
$response = $this->notificationsController->dismiss('42');
298+
$this->assertInstanceOf(DataResponse::class, $response);
299+
$this->assertSame(404, $response->getStatus());
300+
}
301+
302+
public function testDismissNonFileComment(): void {
303+
$comment = $this->createMock(IComment::class);
304+
$comment->expects($this->any())
305+
->method('getObjectType')
306+
->willReturn('calendar');
307+
308+
$this->commentsManager->expects($this->once())
309+
->method('get')
310+
->with('42')
311+
->willReturn($comment);
312+
313+
$user = $this->createMock(IUser::class);
314+
$this->session->expects($this->once())
315+
->method('getUser')
316+
->willReturn($user);
317+
318+
$this->notificationManager->expects($this->never())
319+
->method('markProcessed');
320+
321+
$response = $this->notificationsController->dismiss('42');
322+
$this->assertInstanceOf(DataResponse::class, $response);
323+
$this->assertSame(404, $response->getStatus());
324+
}
226325
}

dist/1252-1252.js

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

0 commit comments

Comments
 (0)