Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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