Skip to content
Draft
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
The table of contents is too big for display.
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 @@ -10,5 +10,6 @@
return [
'routes' => [
['name' => 'Notifications#view', 'url' => '/notifications/view/{id}', 'verb' => 'GET'],
['name' => 'Notifications#dismiss', 'url' => '/notifications/{id}', 'verb' => 'DELETE'],
]
];
33 changes: 33 additions & 0 deletions apps/comments/lib/Controller/NotificationsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@

use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
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;
use OCP\Comments\ICommentsManager;
use OCP\Comments\NotFoundException;
use OCP\Files\IRootFolder;
use OCP\IRequest;
use OCP\IURLGenerator;
Expand Down Expand Up @@ -93,6 +96,36 @@ public function view(string $id): RedirectResponse|NotFoundResponse {
}
}

/**
* Dismiss the mention notification for a comment
*
* @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
*/
#[NoAdminRequired]
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 (NotFoundException|\InvalidArgumentException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessarily a blocker for this narrow endpoint, but narrowing the caught exception to comment lookup related failures would avoid masking unrelated errors as missing comments and improve diagnosability.

}
}

/**
* Marks the notification about a comment as processed
*/
Expand Down
24 changes: 24 additions & 0 deletions apps/comments/src/comments-activity-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
import type { INode } from '@nextcloud/files'
import type { App } from 'vue'

import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import logger from './logger.ts'
import { getComments } from './services/GetComments.ts'
import { markCommentsAsRead } from './services/ReadComments.ts'

/**
* Register the comments plugins for the Activity sidebar
Expand Down Expand Up @@ -51,6 +55,26 @@ export function registerCommentsPlugins() {
},
)
logger.debug('Loaded comments', { node, comments })

// Mark all comments as read and clear the unread badge in the files list
if (node.fileid) {
markCommentsAsRead('files', node.fileid, new Date())
.then(() => node.update({ 'comments-unread': 0 }))
.catch((error) => logger.debug('Failed to mark comments as read', { fileId: node.fileid, error }))
}

// 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((error) => logger.debug('Failed to dismiss mention notification', { commentId: comment.props.id, error }))
}
}
}
const { default: CommentView } = await import('./views/ActivityCommentEntry.vue')

return comments.map((comment) => ({
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 @@ -11,6 +11,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 @@ -164,6 +165,104 @@ public function testViewInvalidComment(): void {
$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());
}

public function testViewNoFile(): void {
$comment = $this->createMock(IComment::class);
$comment->expects($this->any())
Expand Down
2 changes: 0 additions & 2 deletions dist/ActivityCommentAction-CsS58yiR.chunk.mjs

This file was deleted.

2 changes: 2 additions & 0 deletions dist/ActivityCommentAction-wGyuqEAj.chunk.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import{b as t}from"./index-CKF1zKZK.chunk.mjs";import{t as m}from"./index-CWUMrdUf.chunk.mjs";import{C as e,a as p}from"./CommentView-BIiNs4T9.chunk.mjs";import{l as i}from"./activity-Be9Oz7-L.chunk.mjs";import{b as a,r as s,o as n,c,m as u}from"./preload-helper-DhNzhW5T.chunk.mjs";import{_ as l}from"./_plugin-vue_export-helper-CqVUm19z.chunk.mjs";import"./index-3z0IAvAj.chunk.mjs";import"./fileSize-C46TX6UY.chunk.mjs";import"./formatRelative-DUaw6luk.chunk.mjs";import"./Web-BGtHImf4.chunk.mjs";import"./Check-D-bILYz8.chunk.mjs";import"./public-DUDgMnHe.chunk.mjs";import"./Plus-CV4WafRD.chunk.mjs";import"./NcModal-CZNsi3nM.chunk.mjs";import"./be-tarask-B_hxH68S.chunk.mjs";import"./index-DdOl00ms.chunk.mjs";import"./util-BUUeB7_Z.chunk.mjs";import"./NcActionButton-CPm_zjfS.chunk.mjs";import"./NcActionSeparator-aXL1B4RE.chunk.mjs";import"./NcAvatar-BJO4m5sJ.chunk.mjs";import"./autolink-BTpHEQAK.chunk.mjs";import"./colors-u8BZMkKJ.chunk.mjs";import"./NcUserStatusIcon-B-b2ahJ5.chunk.mjs";import"./NcActionLink-B4NYe84b.chunk.mjs";import"./NcActionRouter-CqaoLT7S.chunk.mjs";import"./NcActionText-DDKnnD6F.chunk.mjs";import"./NcUserBubble-Cw8w26Jt.chunk.mjs";import"./ReadComments-Bd0F4W3y.chunk.mjs";import"./index-BsIqSP4H.chunk.mjs";import"./dav-DfavsUiO.chunk.mjs";import"./externalStorageUtils-59-zbcvF.chunk.mjs";const d=a({components:{CommentEntry:p},mixins:[e],props:{reloadCallback:{type:Function,required:!0}},methods:{onNewComment(){try{this.reloadCallback()}catch(o){t(m("comments","Could not reload comments")),i.error("Could not reload comments",{error:o})}}}});function C(o,f,y,b,w,N){const r=s("CommentEntry");return n(),c(r,u(o.editorData,{autoComplete:o.autoComplete,resourceType:o.resourceType,editor:!0,userData:o.userData,resourceId:o.resourceId,class:"comments-action",onNew:o.onNewComment}),null,16,["autoComplete","resourceType","userData","resourceId","onNew"])}const Y=l(d,[["render",C],["__scopeId","data-v-099b6b12"]]);export{Y as default};
//# sourceMappingURL=ActivityCommentAction-wGyuqEAj.chunk.mjs.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions dist/ActivityCommentEntry-CYrXxRSQ.chunk.mjs

This file was deleted.

2 changes: 2 additions & 0 deletions dist/ActivityCommentEntry-eCMSkzdM.chunk.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import{t as s}from"./index-CWUMrdUf.chunk.mjs";import{C as p,a as i}from"./CommentView-BIiNs4T9.chunk.mjs";import{_ as a}from"./_plugin-vue_export-helper-CqVUm19z.chunk.mjs";import{r as n,o as c,c as u,m as d}from"./preload-helper-DhNzhW5T.chunk.mjs";import"./public-DUDgMnHe.chunk.mjs";import"./index-DdOl00ms.chunk.mjs";import"./util-BUUeB7_Z.chunk.mjs";import"./NcActionButton-CPm_zjfS.chunk.mjs";import"./Check-D-bILYz8.chunk.mjs";import"./Web-BGtHImf4.chunk.mjs";import"./NcModal-CZNsi3nM.chunk.mjs";import"./fileSize-C46TX6UY.chunk.mjs";import"./formatRelative-DUaw6luk.chunk.mjs";import"./NcActionSeparator-aXL1B4RE.chunk.mjs";import"./NcAvatar-BJO4m5sJ.chunk.mjs";import"./autolink-BTpHEQAK.chunk.mjs";import"./colors-u8BZMkKJ.chunk.mjs";import"./NcUserStatusIcon-B-b2ahJ5.chunk.mjs";import"./NcActionLink-B4NYe84b.chunk.mjs";import"./NcActionRouter-CqaoLT7S.chunk.mjs";import"./NcActionText-DDKnnD6F.chunk.mjs";import"./Plus-CV4WafRD.chunk.mjs";import"./index-3z0IAvAj.chunk.mjs";import"./NcUserBubble-Cw8w26Jt.chunk.mjs";import"./be-tarask-B_hxH68S.chunk.mjs";import"./index-CKF1zKZK.chunk.mjs";import"./activity-Be9Oz7-L.chunk.mjs";import"./ReadComments-Bd0F4W3y.chunk.mjs";import"./index-BsIqSP4H.chunk.mjs";import"./dav-DfavsUiO.chunk.mjs";import"./externalStorageUtils-59-zbcvF.chunk.mjs";const l={name:"ActivityCommentEntry",components:{CommentEntry:i},mixins:[p],props:{comment:{type:Object,required:!0},reloadCallback:{type:Function,required:!0}},data(){return{commentMessage:""}},watch:{comment(){this.commentMessage=this.comment.props.message}},mounted(){this.commentMessage=this.comment.props.message},methods:{t:s}};function g(t,e,o,y,m,C){const r=n("CommentEntry");return c(),u(r,d({ref:"comment",tag:"li"},o.comment.props,{autoComplete:t.autoComplete,resourceType:t.resourceType,message:m.commentMessage,resourceId:t.resourceId,userData:t.genMentionsData(o.comment.props.mentions),class:"comments-activity",onDelete:e[0]||(e[0]=f=>o.reloadCallback())}),null,16,["autoComplete","resourceType","message","resourceId","userData"])}const W=a(l,[["render",g],["__scopeId","data-v-2d51dbfd"]]);export{W as default};
//# sourceMappingURL=ActivityCommentEntry-eCMSkzdM.chunk.mjs.map
Loading
Loading