fix(comments): mark comments as read and dismiss notifications in Activity sidebar

Backport of #60617 to stable32.

When the Activity app integration is active, comments are loaded in the
Activity sidebar instead of the Comments tab. The read marker and mention
notification dismissal were never triggered in this path, leaving the
unread bubble in the file list and keeping mention notifications active.

Also adds a new DELETE /notifications/{id} endpoint to dismiss individual
mention notifications by comment ID.

AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Anna Larch <anna@nextcloud.com>
This commit is contained in:
Anna Larch 2026-05-20 18:24:21 +02:00
parent d0c2d97202
commit 4aac879e21
7 changed files with 171 additions and 3 deletions

View file

@ -8,5 +8,6 @@
return [
'routes' => [
['name' => 'Notifications#view', 'url' => '/notifications/view/{id}', 'verb' => 'GET'],
['name' => 'Notifications#dismiss', 'url' => '/notifications/{id}', 'verb' => 'DELETE'],
]
];

View file

@ -11,6 +11,7 @@ use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\Comments\IComment;
@ -92,6 +93,37 @@ class NotificationsController extends Controller {
}
}
/**
* Dismiss the mention notification for a comment
*
* @NoAdminRequired
*
* @param string $id ID of the comment
*
* @return DataResponse<Http::STATUS_OK, array{}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{}, array{}>
*
* 200: Notification dismissed successfully
* 403: Not logged in
* 404: Comment not found
*/
public function dismiss(string $id): DataResponse {
$currentUser = $this->userSession->getUser();
if (!$currentUser instanceof IUser) {
return new DataResponse([], Http::STATUS_FORBIDDEN);
}
try {
$comment = $this->commentsManager->get($id);
if ($comment->getObjectType() !== 'files') {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
$this->markProcessed($comment, $currentUser);
return new DataResponse([]);
} catch (\Exception $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
}
/**
* Marks the notification about a comment as processed
*/

View file

@ -2,10 +2,14 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import moment from '@nextcloud/moment'
import { generateUrl } from '@nextcloud/router'
import Vue, { type ComponentPublicInstance } from 'vue'
import logger from './logger.js'
import { getComments } from './services/GetComments.js'
import { markCommentsAsRead } from './services/ReadComments.js'
import { PiniaVuePlugin, createPinia } from 'pinia'
@ -48,6 +52,31 @@ export function registerCommentsPlugins() {
window.OCA.Activity.registerSidebarEntries(async ({ fileInfo, limit, offset }) => {
const { data: comments } = await getComments({ resourceType: 'files', resourceId: fileInfo.id }, { limit, offset })
logger.debug('Loaded comments', { fileInfo, comments })
// Optimistically clear the unread bubble immediately via the global event bus
// (window._nc_event_bus) so the UI updates without a page refresh.
// fileInfo.node is the underlying @nextcloud/files Node set by the Files sidebar.
const node = fileInfo.node
if (node) {
node.attributes['comments-unread'] = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any)._nc_event_bus?.emit('files:node:updated', node)
}
markCommentsAsRead('files', fileInfo.id, new Date()).catch(() => {})
// Mark mention notifications as read for comments that mention the current user
const currentUser = getCurrentUser()
if (currentUser) {
for (const comment of comments) {
const mentions = Object.values(comment.props?.mentions ?? {}) as { mentionType: string, mentionId: string }[]
const isMentioned = comment.props?.id && mentions.some((m) => m.mentionType === 'user' && m.mentionId === currentUser.uid)
if (isMentioned) {
axios.delete(generateUrl('/apps/comments/notifications/{id}', { id: comment.props.id }))
.catch(() => {})
}
}
}
const { default: CommentView } = await import('./views/ActivityCommentEntry.vue')
// @ts-expect-error Types are broken for Vue2
const CommentsViewObject = Vue.extend(CommentView)

View file

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace OCA\Comments\Tests\Unit\Controller;
use OCA\Comments\Controller\NotificationsController;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\Comments\IComment;
@ -211,4 +212,102 @@ class NotificationsTest extends TestCase {
$response = $this->notificationsController->view('42');
$this->assertInstanceOf(NotFoundResponse::class, $response);
}
public function testDismissNotLoggedIn(): void {
$this->session->expects($this->once())
->method('getUser')
->willReturn(null);
$this->commentsManager->expects($this->never())
->method('get');
$this->notificationManager->expects($this->never())
->method('markProcessed');
$response = $this->notificationsController->dismiss('42');
$this->assertInstanceOf(DataResponse::class, $response);
$this->assertSame(403, $response->getStatus());
}
public function testDismissSuccess(): void {
$comment = $this->createMock(IComment::class);
$comment->expects($this->any())
->method('getObjectType')
->willReturn('files');
$comment->expects($this->any())
->method('getId')
->willReturn('1234');
$this->commentsManager->expects($this->once())
->method('get')
->with('42')
->willReturn($comment);
$user = $this->createMock(IUser::class);
$user->expects($this->any())
->method('getUID')
->willReturn('user');
$this->session->expects($this->once())
->method('getUser')
->willReturn($user);
$notification = $this->createMock(INotification::class);
$notification->expects($this->any())
->method($this->anything())
->willReturn($notification);
$this->notificationManager->expects($this->once())
->method('createNotification')
->willReturn($notification);
$this->notificationManager->expects($this->once())
->method('markProcessed')
->with($notification);
$response = $this->notificationsController->dismiss('42');
$this->assertInstanceOf(DataResponse::class, $response);
$this->assertSame(200, $response->getStatus());
}
public function testDismissInvalidComment(): void {
$this->commentsManager->expects($this->once())
->method('get')
->with('42')
->willThrowException(new NotFoundException());
$user = $this->createMock(IUser::class);
$this->session->expects($this->once())
->method('getUser')
->willReturn($user);
$this->notificationManager->expects($this->never())
->method('markProcessed');
$response = $this->notificationsController->dismiss('42');
$this->assertInstanceOf(DataResponse::class, $response);
$this->assertSame(404, $response->getStatus());
}
public function testDismissNonFileComment(): void {
$comment = $this->createMock(IComment::class);
$comment->expects($this->any())
->method('getObjectType')
->willReturn('calendar');
$this->commentsManager->expects($this->once())
->method('get')
->with('42')
->willReturn($comment);
$user = $this->createMock(IUser::class);
$this->session->expects($this->once())
->method('getUser')
->willReturn($user);
$this->notificationManager->expects($this->never())
->method('markProcessed');
$response = $this->notificationsController->dismiss('42');
$this->assertInstanceOf(DataResponse::class, $response);
$this->assertSame(404, $response->getStatus());
}
}

File diff suppressed because one or more lines are too long

View file

@ -21,6 +21,7 @@ SPDX-FileCopyrightText: Paul Vorbach <paul@vorb.de> (http://vorb.de)
SPDX-FileCopyrightText: Olivier Scherrer <pode.fr@gmail.com>
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
SPDX-FileCopyrightText: Nathan Rajlich <nathan@tootallnate.net> (http://n8.io/)
SPDX-FileCopyrightText: Matt Zabriskie
SPDX-FileCopyrightText: Mathias Bynens
SPDX-FileCopyrightText: Julian Gruber
SPDX-FileCopyrightText: Joyent
@ -48,6 +49,9 @@ This file is generated from multiple sources. Included packages:
- @nextcloud/auth
- version: 2.6.0
- license: GPL-3.0-or-later
- @nextcloud/axios
- version: 2.5.2
- license: GPL-3.0-or-later
- @nextcloud/browser-storage
- version: 0.5.0
- license: GPL-3.0-or-later
@ -81,6 +85,9 @@ This file is generated from multiple sources. Included packages:
- available-typed-arrays
- version: 1.0.7
- license: MIT
- axios
- version: 1.13.4
- license: MIT
- balanced-match
- version: 1.0.2
- license: MIT

File diff suppressed because one or more lines are too long