Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/comments/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
return [
'routes' => [
['name' => 'Notifications#view', 'url' => '/notifications/view/{id}', 'verb' => 'GET'],
['name' => 'Notifications#dismiss', 'url' => '/notifications/{id}', 'verb' => 'DELETE'],
]
];
32 changes: 32 additions & 0 deletions apps/comments/lib/Controller/NotificationsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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;
Expand Down Expand Up @@ -92,6 +93,37 @@ public function view(string $id): RedirectResponse|NotFoundResponse {
}
}

/**
* 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
*/
Expand Down
29 changes: 29 additions & 0 deletions apps/comments/src/comments-activity-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down
99 changes: 99 additions & 0 deletions apps/comments/tests/Unit/Controller/NotificationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -211,4 +212,102 @@ public function testViewNoFile(): void {
$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());
}
}
Loading
Loading