Merge pull request #1537 from nextcloud/enh/comments-reply
Comments reply
This commit is contained in:
@@ -3,6 +3,6 @@ module.exports = {
|
|||||||
'nextcloud'
|
'nextcloud'
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'valid-jsdoc': ['warn'],
|
'valid-jsdoc': ['off'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ return [
|
|||||||
['name' => 'attachment_api#delete', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId}', 'verb' => 'DELETE'],
|
['name' => 'attachment_api#delete', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId}', 'verb' => 'DELETE'],
|
||||||
['name' => 'attachment_api#restore', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId}/restore', 'verb' => 'PUT'],
|
['name' => 'attachment_api#restore', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId}/restore', 'verb' => 'PUT'],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
['name' => 'board_api#preflighted_cors', 'url' => '/api/v1.0/{path}','verb' => 'OPTIONS', 'requirements' => ['path' => '.+']],
|
['name' => 'board_api#preflighted_cors', 'url' => '/api/v1.0/{path}','verb' => 'OPTIONS', 'requirements' => ['path' => '.+']],
|
||||||
|
],
|
||||||
|
'ocs' => [
|
||||||
|
['name' => 'comments_api#list', 'url' => '/api/v1.0/cards/{cardId}/comments', 'verb' => 'GET'],
|
||||||
|
['name' => 'comments_api#create', 'url' => '/api/v1.0/cards/{cardId}/comments', 'verb' => 'POST'],
|
||||||
|
['name' => 'comments_api#update', 'url' => '/api/v1.0/cards/{cardId}/comments/{commentId}', 'verb' => 'PUT'],
|
||||||
|
['name' => 'comments_api#delete', 'url' => '/api/v1.0/cards/{cardId}/comments/{commentId}', 'verb' => 'DELETE'],
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
@include icon-black-white('clone', 'deck', 1);
|
@include icon-black-white('clone', 'deck', 1);
|
||||||
@include icon-black-white('filter', 'deck', 1);
|
@include icon-black-white('filter', 'deck', 1);
|
||||||
@include icon-black-white('attach', 'deck', 1);
|
@include icon-black-white('attach', 'deck', 1);
|
||||||
|
@include icon-black-white('reply', 'deck', 1);
|
||||||
|
|
||||||
.icon-toggle-compact-collapsed {
|
.icon-toggle-compact-collapsed {
|
||||||
@include icon-color('toggle-view-expand', 'deck', $color-black);
|
@include icon-color('toggle-view-expand', 'deck', $color-black);
|
||||||
|
|||||||
229
docs/API.md
229
docs/API.md
@@ -936,3 +936,232 @@ For now only `deck_file` is supported as an attachment type.
|
|||||||
|
|
||||||
##### 200 Success
|
##### 200 Success
|
||||||
|
|
||||||
|
# OCS API
|
||||||
|
|
||||||
|
The following endpoints are available tough the Nextcloud OCS endpoint, which is available at `/ocs/v2.php/apps/deck/api/v1.0/`.
|
||||||
|
This has the benefit that both the web UI as well as external integrations can use the same API.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
### GET /cards/{cardId}/comments - List comments
|
||||||
|
|
||||||
|
#### Request parameters
|
||||||
|
|
||||||
|
string $cardId, int $limit = 20, int $offset = 0
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ------- | --------------------------------------- |
|
||||||
|
| cardId | Integer | The id of the card |
|
||||||
|
| limit | Integer | The maximum number of comments that should be returned, defaults to 20 |
|
||||||
|
| offset | Integer | The start offset used for pagination, defaults to 0 |
|
||||||
|
|
||||||
|
```
|
||||||
|
curl 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/cards/12/comments' \
|
||||||
|
-H 'Accept: application/json' -H 'OCS-APIRequest: true'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
A list of comments will be provided under the `ocs.data` key. If no or no more comments are available the list will be empty.
|
||||||
|
|
||||||
|
##### 200 Success
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"ocs": {
|
||||||
|
"meta": {
|
||||||
|
"status": "ok",
|
||||||
|
"statuscode": 200,
|
||||||
|
"message": "OK"
|
||||||
|
},
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "175",
|
||||||
|
"objectId": "12",
|
||||||
|
"message": "This is a comment with a mention to @alice",
|
||||||
|
"actorId": "admin",
|
||||||
|
"actorType": "users",
|
||||||
|
"actorDisplayName": "Administrator",
|
||||||
|
"creationDateTime": "2020-03-10T10:23:07+00:00",
|
||||||
|
"mentions": [
|
||||||
|
{
|
||||||
|
"mentionId": "alice",
|
||||||
|
"mentionType": "user",
|
||||||
|
"mentionDisplayName": "alice"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### POST /cards/{cardId}/comments - Create a new comment
|
||||||
|
|
||||||
|
#### Request parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ------- | --------------------------------------- |
|
||||||
|
| cardId | Integer | The id of the card |
|
||||||
|
| message | String | The message of the comment, maximum length is limited to 1000 characters |
|
||||||
|
| parentId | Integer | The start offset used for pagination, defaults to null |
|
||||||
|
|
||||||
|
Mentions will be parsed by the server. The server will return a list of mentions in the response to this request as shown below.
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/cards/12/comments' \
|
||||||
|
-H 'Accept: application/json' -H 'OCS-APIRequest: true'
|
||||||
|
-H 'Content-Type: application/json;charset=utf-8'
|
||||||
|
--data '{"message":"My message to @bob","parentId":null}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
A list of comments will be provided under the `ocs.data` key. If no or no more comments are available the list will be empty.
|
||||||
|
|
||||||
|
##### 200 Success
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"ocs": {
|
||||||
|
"meta": {
|
||||||
|
"status": "ok",
|
||||||
|
"statuscode": 200,
|
||||||
|
"message": "OK"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"id": "177",
|
||||||
|
"objectId": "13",
|
||||||
|
"message": "My message to @bob",
|
||||||
|
"actorId": "admin",
|
||||||
|
"actorType": "users",
|
||||||
|
"actorDisplayName": "Administrator",
|
||||||
|
"creationDateTime": "2020-03-10T10:30:17+00:00",
|
||||||
|
"mentions": [
|
||||||
|
{
|
||||||
|
"mentionId": "bob",
|
||||||
|
"mentionType": "user",
|
||||||
|
"mentionDisplayName": "bob"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 400 Bad request
|
||||||
|
|
||||||
|
A bad request response is returned if invalid input values are provided. The response message will contain details about which part was not valid.
|
||||||
|
|
||||||
|
##### 404 Not found
|
||||||
|
|
||||||
|
A not found response might be returned if:
|
||||||
|
- The card for the given cardId could not be found
|
||||||
|
- The parent comment could not be found
|
||||||
|
|
||||||
|
|
||||||
|
### PUT /cards/{cardId}/comments/{commentId} - Update a new comment
|
||||||
|
|
||||||
|
#### Request parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ------- | --------------------------------------- |
|
||||||
|
| cardId | Integer | The id of the card |
|
||||||
|
| commentId | Integer | The id of the comment |
|
||||||
|
| message | String | The message of the comment, maximum length is limited to 1000 characters |
|
||||||
|
|
||||||
|
Mentions will be parsed by the server. The server will return a list of mentions in the response to this request as shown below.
|
||||||
|
|
||||||
|
Updating comments is limited to the current user being the same as the comment author specified in the `actorId` of the comment.
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/cards/12/comments' \
|
||||||
|
-H 'Accept: application/json' -H 'OCS-APIRequest: true'
|
||||||
|
-H 'Content-Type: application/json;charset=utf-8'
|
||||||
|
--data '{"message":"My message"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
A list of comments will be provided under the `ocs.data` key. If no or no more comments are available the list will be empty.
|
||||||
|
|
||||||
|
##### 200 Success
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"ocs": {
|
||||||
|
"meta": {
|
||||||
|
"status": "ok",
|
||||||
|
"statuscode": 200,
|
||||||
|
"message": "OK"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"id": "177",
|
||||||
|
"objectId": "13",
|
||||||
|
"message": "My message",
|
||||||
|
"actorId": "admin",
|
||||||
|
"actorType": "users",
|
||||||
|
"actorDisplayName": "Administrator",
|
||||||
|
"creationDateTime": "2020-03-10T10:30:17+00:00",
|
||||||
|
"mentions": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 400 Bad request
|
||||||
|
|
||||||
|
A bad request response is returned if invalid input values are provided. The response message will contain details about which part was not valid.
|
||||||
|
|
||||||
|
##### 404 Not found
|
||||||
|
|
||||||
|
A not found response might be returned if:
|
||||||
|
- The card for the given cardId could not be found
|
||||||
|
- The comment could not be found
|
||||||
|
|
||||||
|
### DELETE /cards/{cardId}/comments/{commentId} - Delete a comment
|
||||||
|
|
||||||
|
#### Request parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ------- | --------------------------------------- |
|
||||||
|
| cardId | Integer | The id of the card |
|
||||||
|
| commentId | Integer | The id of the comment |
|
||||||
|
|
||||||
|
Deleting comments is limited to the current user being the same as the comment author specified in the `actorId` of the comment.
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X DELETE 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/cards/12/comments' \
|
||||||
|
-H 'Accept: application/json' -H 'OCS-APIRequest: true'
|
||||||
|
-H 'Content-Type: application/json;charset=utf-8'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
A list of comments will be provided under the `ocs.data` key. If no or no more comments are available the list will be empty.
|
||||||
|
|
||||||
|
##### 200 Success
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"ocs": {
|
||||||
|
"meta": {
|
||||||
|
"status": "ok",
|
||||||
|
"statuscode": 200,
|
||||||
|
"message": "OK"
|
||||||
|
},
|
||||||
|
"data": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 400 Bad request
|
||||||
|
|
||||||
|
A bad request response is returned if invalid input values are provided. The response message will contain details about which part was not valid.
|
||||||
|
|
||||||
|
##### 404 Not found
|
||||||
|
|
||||||
|
A not found response might be returned if:
|
||||||
|
- The card for the given cardId could not be found
|
||||||
|
- The comment could not be found
|
||||||
|
|||||||
1
img/reply.svg
Normal file
1
img/reply.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M15 15s-.4-7.8-7-10V1L1 8l7 7v-4c5.1 0 7 4 7 4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -56,6 +56,8 @@ class Application extends App {
|
|||||||
|
|
||||||
public const APP_ID = 'deck';
|
public const APP_ID = 'deck';
|
||||||
|
|
||||||
|
public const COMMENT_ENTITY_TYPE = 'deckCard';
|
||||||
|
|
||||||
/** @var IServerContainer */
|
/** @var IServerContainer */
|
||||||
private $server;
|
private $server;
|
||||||
|
|
||||||
@@ -149,7 +151,7 @@ class Application extends App {
|
|||||||
|
|
||||||
public function registerCommentsEntity(): void {
|
public function registerCommentsEntity(): void {
|
||||||
$this->server->getEventDispatcher()->addListener(CommentsEntityEvent::EVENT_ENTITY, function(CommentsEntityEvent $event) {
|
$this->server->getEventDispatcher()->addListener(CommentsEntityEvent::EVENT_ENTITY, function(CommentsEntityEvent $event) {
|
||||||
$event->addEntityCollection('deckCard', function($name) {
|
$event->addEntityCollection(self::COMMENT_ENTITY_TYPE, function($name) {
|
||||||
/** @var CardMapper */
|
/** @var CardMapper */
|
||||||
$cardMapper = $this->getContainer()->query(CardMapper::class);
|
$cardMapper = $this->getContainer()->query(CardMapper::class);
|
||||||
$permissionService = $this->getContainer()->query(PermissionService::class);
|
$permissionService = $this->getContainer()->query(PermissionService::class);
|
||||||
|
|||||||
80
lib/Controller/CommentsApiController.php
Normal file
80
lib/Controller/CommentsApiController.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
|
||||||
|
*
|
||||||
|
* @author Julius Härtl <jus@bitgrid.net>
|
||||||
|
*
|
||||||
|
* @license GNU AGPL version 3 or any later version
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace OCA\Deck\Controller;
|
||||||
|
|
||||||
|
use OCA\Deck\BadRequestException;
|
||||||
|
use OCA\Deck\Service\CommentService;
|
||||||
|
use OCA\Deck\StatusException;
|
||||||
|
use OCP\AppFramework\ApiController;
|
||||||
|
use OCP\AppFramework\Http\DataResponse;
|
||||||
|
|
||||||
|
use OCP\AppFramework\OCS\OCSException;
|
||||||
|
use OCP\AppFramework\OCSController;
|
||||||
|
use OCP\IRequest;
|
||||||
|
|
||||||
|
class CommentsApiController extends OCSController {
|
||||||
|
|
||||||
|
/** @var CommentService */
|
||||||
|
private $commentService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
$appName, IRequest $request, $corsMethods = 'PUT, POST, GET, DELETE, PATCH', $corsAllowedHeaders = 'Authorization, Content-Type, Accept', $corsMaxAge = 1728000,
|
||||||
|
CommentService $commentService
|
||||||
|
) {
|
||||||
|
parent::__construct($appName, $request, $corsMethods, $corsAllowedHeaders, $corsMaxAge);
|
||||||
|
$this->commentService = $commentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoAdminRequired
|
||||||
|
* @throws StatusException
|
||||||
|
*/
|
||||||
|
public function list(string $cardId, int $limit = 20, int $offset = 0): DataResponse {
|
||||||
|
return $this->commentService->list($cardId, $limit, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoAdminRequired
|
||||||
|
* @throws StatusException
|
||||||
|
*/
|
||||||
|
public function create(string $cardId, string $message, string $parentId = '0'): DataResponse {
|
||||||
|
return $this->commentService->create($cardId, $message, $parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoAdminRequired
|
||||||
|
* @throws StatusException
|
||||||
|
*/
|
||||||
|
public function update(string $cardId, string $commentId, string $message): DataResponse {
|
||||||
|
return $this->commentService->update($cardId, $commentId, $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoAdminRequired
|
||||||
|
* @throws StatusException
|
||||||
|
*/
|
||||||
|
public function delete(string $cardId, string $commentId): DataResponse {
|
||||||
|
return $this->commentService->delete($cardId, $commentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ use OCA\Deck\StatusException;
|
|||||||
use OCP\AppFramework\Db\DoesNotExistException;
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
use OCP\AppFramework\Middleware;
|
use OCP\AppFramework\Middleware;
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
|
use OCP\AppFramework\OCS\OCSException;
|
||||||
|
use OCP\AppFramework\OCSController;
|
||||||
use OCP\ILogger;
|
use OCP\ILogger;
|
||||||
use OCP\Util;
|
use OCP\Util;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
@@ -65,6 +67,11 @@ class ExceptionMiddleware extends Middleware {
|
|||||||
if ($this->config->getSystemValue('loglevel', Util::WARN) === Util::DEBUG) {
|
if ($this->config->getSystemValue('loglevel', Util::WARN) === Util::DEBUG) {
|
||||||
$this->logger->logException($exception);
|
$this->logger->logException($exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($controller instanceof OCSController) {
|
||||||
|
$exception = new OCSException($exception->getMessage(), $exception->getStatus(), $exception);
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
return new JSONResponse([
|
return new JSONResponse([
|
||||||
'status' => $exception->getStatus(),
|
'status' => $exception->getStatus(),
|
||||||
'message' => $exception->getMessage()
|
'message' => $exception->getMessage()
|
||||||
|
|||||||
191
lib/Service/CommentService.php
Normal file
191
lib/Service/CommentService.php
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
|
||||||
|
*
|
||||||
|
* @author Julius Härtl <jus@bitgrid.net>
|
||||||
|
*
|
||||||
|
* @license GNU AGPL version 3 or any later version
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace OCA\Deck\Service;
|
||||||
|
|
||||||
|
|
||||||
|
use OCA\Deck\AppInfo\Application;
|
||||||
|
use OCA\Deck\BadRequestException;
|
||||||
|
use OCA\Deck\Db\Acl;
|
||||||
|
use OCA\Deck\Db\CardMapper;
|
||||||
|
use OCA\Deck\NoPermissionException;
|
||||||
|
use OCA\Deck\NotFoundException;
|
||||||
|
use OCA\Deck\StatusException;
|
||||||
|
use OCP\AppFramework\Http\DataResponse;
|
||||||
|
use OCP\Comments\IComment;
|
||||||
|
use OCP\Comments\ICommentsManager;
|
||||||
|
use OCP\Comments\MessageTooLongException;
|
||||||
|
use OCP\Comments\NotFoundException as CommentNotFoundException;
|
||||||
|
use OCP\ILogger;
|
||||||
|
use OCP\IUserManager;
|
||||||
|
use OutOfBoundsException;
|
||||||
|
use Sabre\DAV\Exception\Forbidden;
|
||||||
|
use function is_numeric;
|
||||||
|
|
||||||
|
class CommentService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ICommentsManager
|
||||||
|
*/
|
||||||
|
private $commentsManager;
|
||||||
|
/**
|
||||||
|
* @var IUserManager
|
||||||
|
*/
|
||||||
|
private $userManager;
|
||||||
|
/** @var ILogger */
|
||||||
|
private $logger;
|
||||||
|
private $userId;
|
||||||
|
|
||||||
|
public function __construct(ICommentsManager $commentsManager, PermissionService $permissionService, CardMapper $cardMapper, IUserManager $userManager, ILogger $logger, $userId) {
|
||||||
|
$this->commentsManager = $commentsManager;
|
||||||
|
$this->permissionService = $permissionService;
|
||||||
|
$this->cardMapper = $cardMapper;
|
||||||
|
$this->userManager = $userManager;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->userId = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $cardId, int $limit = 20, int $offset = 0): DataResponse {
|
||||||
|
if (!is_numeric($cardId)) {
|
||||||
|
throw new BadRequestException('A valid card id must be provided');
|
||||||
|
}
|
||||||
|
$this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ);
|
||||||
|
$comments = $this->commentsManager->getForObject(Application::COMMENT_ENTITY_TYPE, $cardId, $limit, $offset);
|
||||||
|
$result = [];
|
||||||
|
foreach ($comments as $comment) {
|
||||||
|
$formattedComment = $this->formatComment($comment);
|
||||||
|
try {
|
||||||
|
if ($comment->getParentId() !== '0' && $replyTo = $this->commentsManager->get($comment->getParentId())) {
|
||||||
|
$formattedComment['replyTo'] = $this->formatComment($replyTo);
|
||||||
|
}
|
||||||
|
} catch (CommentNotFoundException $e) {
|
||||||
|
}
|
||||||
|
$result[] = $formattedComment;
|
||||||
|
}
|
||||||
|
return new DataResponse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $cardId
|
||||||
|
* @param string $message
|
||||||
|
* @param string $replyTo
|
||||||
|
* @return DataResponse
|
||||||
|
* @throws BadRequestException
|
||||||
|
* @throws NotFoundException
|
||||||
|
*/
|
||||||
|
public function create(string $cardId, string $message, string $replyTo = '0'): DataResponse {
|
||||||
|
if (!is_numeric($cardId)) {
|
||||||
|
throw new BadRequestException('A valid card id must be provided');
|
||||||
|
}
|
||||||
|
$this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ);
|
||||||
|
try {
|
||||||
|
$comment = $this->commentsManager->create('users', $this->userId, Application::COMMENT_ENTITY_TYPE, $cardId);
|
||||||
|
$comment->setMessage($message);
|
||||||
|
$comment->setVerb('comment');
|
||||||
|
$comment->setParentId($replyTo);
|
||||||
|
$this->commentsManager->save($comment);
|
||||||
|
return new DataResponse($this->formatComment($comment));
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
throw new BadRequestException('Invalid input values');
|
||||||
|
} catch (MessageTooLongException $e) {
|
||||||
|
$msg = 'Message exceeds allowed character limit of ';
|
||||||
|
throw new BadRequestException($msg . IComment::MAX_MESSAGE_LENGTH);
|
||||||
|
} catch (CommentNotFoundException $e) {
|
||||||
|
throw new NotFoundException('Could not create comment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(string $cardId, string $commentId, string $message): DataResponse {
|
||||||
|
if (!is_numeric($cardId)) {
|
||||||
|
throw new BadRequestException('A valid card id must be provided');
|
||||||
|
}
|
||||||
|
if (!is_numeric($commentId)) {
|
||||||
|
throw new BadRequestException('A valid comment id must be provided');
|
||||||
|
}
|
||||||
|
$this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ);
|
||||||
|
try {
|
||||||
|
$comment = $this->commentsManager->get($commentId);
|
||||||
|
|
||||||
|
} catch (CommentNotFoundException $e) {
|
||||||
|
throw new NotFoundException('No comment found.');
|
||||||
|
}
|
||||||
|
if ($comment->getActorType() !== 'users' || $comment->getActorId() !== $this->userId) {
|
||||||
|
throw new NoPermissionException('Only authors are allowed to edit their comment.');
|
||||||
|
}
|
||||||
|
$comment->setMessage($message);
|
||||||
|
$this->commentsManager->save($comment);
|
||||||
|
return new DataResponse($this->formatComment($comment));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $cardId, string $commentId): DataResponse {
|
||||||
|
if (!is_numeric($cardId)) {
|
||||||
|
throw new BadRequestException('A valid card id must be provided');
|
||||||
|
}
|
||||||
|
if (!is_numeric($commentId)) {
|
||||||
|
throw new BadRequestException('A valid comment id must be provided');
|
||||||
|
}
|
||||||
|
$this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ);
|
||||||
|
try {
|
||||||
|
$comment = $this->commentsManager->get($commentId);
|
||||||
|
|
||||||
|
} catch (CommentNotFoundException $e) {
|
||||||
|
throw new NotFoundException('No comment found.');
|
||||||
|
}
|
||||||
|
if ($comment->getActorType() !== 'users' || $comment->getActorId() !== $this->userId) {
|
||||||
|
throw new NoPermissionException('Only authors are allowed to edit their comment.');
|
||||||
|
}
|
||||||
|
$this->commentsManager->delete($commentId);
|
||||||
|
return new DataResponse([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatComment(IComment $comment): array {
|
||||||
|
$user = $this->userManager->get($comment->getActorId());
|
||||||
|
$actorDisplayName = $user !== null ? $user->getDisplayName() : $comment->getActorId();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $comment->getId(),
|
||||||
|
'objectId' => $comment->getObjectId(),
|
||||||
|
'message' => $comment->getMessage(),
|
||||||
|
'actorId' => $comment->getActorId(),
|
||||||
|
'actorType' => $comment->getActorType(),
|
||||||
|
'actorDisplayName' => $actorDisplayName,
|
||||||
|
'creationDateTime' => $comment->getCreationDateTime()->format(\DateTime::ATOM),
|
||||||
|
'mentions' => array_map(function($mention) {
|
||||||
|
try {
|
||||||
|
$displayName = $this->commentsManager->resolveDisplayName($mention['type'], $mention['id']);
|
||||||
|
} catch (OutOfBoundsException $e) {
|
||||||
|
$this->logger->logException($e);
|
||||||
|
// No displayname, upon client's discretion what to display.
|
||||||
|
$displayName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'mentionId' => $mention['id'],
|
||||||
|
'mentionType' => $mention['type'],
|
||||||
|
'mentionDisplayName' => $displayName
|
||||||
|
];
|
||||||
|
}, $comment->getMentions()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
5
package-lock.json
generated
5
package-lock.json
generated
@@ -5663,6 +5663,11 @@
|
|||||||
"integrity": "sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==",
|
"integrity": "sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"blueimp-md5": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-zo+HIdIhzojv6F1siQPqPFROyVy7C50KzHv/k/Iz+BtvtVzSHXiMXOpq2wCfNkeBqdCv+V8XOV96tsEt2W/3rQ=="
|
||||||
|
},
|
||||||
"bn.js": {
|
"bn.js": {
|
||||||
"version": "4.11.8",
|
"version": "4.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@nextcloud/moment": "^1.1.0",
|
"@nextcloud/moment": "^1.1.0",
|
||||||
"@nextcloud/router": "^1.0.0",
|
"@nextcloud/router": "^1.0.0",
|
||||||
"@nextcloud/vue": "^1.4.0",
|
"@nextcloud/vue": "^1.4.0",
|
||||||
|
"blueimp-md5": "^2.12.0",
|
||||||
"dompurify": "^2.0.8",
|
"dompurify": "^2.0.8",
|
||||||
"nextcloud-vue-collections": "^0.7.2",
|
"nextcloud-vue-collections": "^0.7.2",
|
||||||
"url-search-params-polyfill": "^8.0.0",
|
"url-search-params-polyfill": "^8.0.0",
|
||||||
|
|||||||
@@ -26,9 +26,10 @@
|
|||||||
<img :src="activity.icon" class="activity--icon">
|
<img :src="activity.icon" class="activity--icon">
|
||||||
<RichText class="activity--subject" :text="message.subject" :arguments="message.parameters" />
|
<RichText class="activity--subject" :text="message.subject" :arguments="message.parameters" />
|
||||||
<div class="activity--timestamp">
|
<div class="activity--timestamp">
|
||||||
{{ getTime(activity.datetime) }}
|
{{ relativeDate(activity.datetime) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- FIXME ins/del tags do no longer work with activity so we should get rid of that -->
|
||||||
<p v-if="activity.message" class="activity--message" v-html="sanitizedMessage" />
|
<p v-if="activity.message" class="activity--message" v-html="sanitizedMessage" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -38,6 +39,7 @@ import RichText from '@juliushaertl/vue-richtext'
|
|||||||
import { UserBubble } from '@nextcloud/vue'
|
import { UserBubble } from '@nextcloud/vue'
|
||||||
import moment from '@nextcloud/moment'
|
import moment from '@nextcloud/moment'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
import relativeDate from '../mixins/relativeDate'
|
||||||
|
|
||||||
const InternalLink = {
|
const InternalLink = {
|
||||||
name: 'InternalLink',
|
name: 'InternalLink',
|
||||||
@@ -61,6 +63,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
RichText,
|
RichText,
|
||||||
},
|
},
|
||||||
|
mixins: [ relativeDate ],
|
||||||
props: {
|
props: {
|
||||||
activity: {
|
activity: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -111,15 +114,7 @@ export default {
|
|||||||
sanitizedMessage() {
|
sanitizedMessage() {
|
||||||
return DOMPurify.sanitize(this.activity.message, { ALLOWED_TAGS: ['ins', 'del'], ALLOWED_ATTR: ['class'] })
|
return DOMPurify.sanitize(this.activity.message, { ALLOWED_TAGS: ['ins', 'del'], ALLOWED_ATTR: ['class'] })
|
||||||
},
|
},
|
||||||
getTime() {
|
|
||||||
return (timestamp) => {
|
|
||||||
const diff = moment(this.$root.time).diff(moment(timestamp))
|
|
||||||
if (diff >= 0 && diff < 45000) {
|
|
||||||
return t('core', 'seconds ago')
|
|
||||||
}
|
|
||||||
return moment(timestamp).fromNow()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CommentItem v-if="replyTo" :comment="replyTo" :reply="true" />
|
||||||
<CommentForm v-model="newComment" @submit="createComment" />
|
<CommentForm v-model="newComment" @submit="createComment" />
|
||||||
|
|
||||||
<ul v-if="getCommentsForCard(card.id).length > 0" id="commentsFeed">
|
<ul v-if="getCommentsForCard(card.id).length > 0" id="commentsFeed">
|
||||||
@@ -58,6 +59,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...mapState({
|
||||||
currentBoard: state => state.currentBoard,
|
currentBoard: state => state.currentBoard,
|
||||||
|
replyTo: state => state.comment.replyTo,
|
||||||
}),
|
}),
|
||||||
...mapGetters([
|
...mapGetters([
|
||||||
'getCommentsForCard',
|
'getCommentsForCard',
|
||||||
@@ -98,6 +100,7 @@ export default {
|
|||||||
comment: content,
|
comment: content,
|
||||||
}
|
}
|
||||||
await this.$store.dispatch('createComment', commentObj)
|
await this.$store.dispatch('createComment', commentObj)
|
||||||
|
this.$store.dispatch('setReplyTo', null)
|
||||||
this.newComment = ''
|
this.newComment = ''
|
||||||
await this.loadComments()
|
await this.loadComments()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
@keydown.enter="handleKeydown"
|
@keydown.enter="handleKeydown"
|
||||||
@paste="onPaste"
|
@paste="onPaste"
|
||||||
@blur="error = null"
|
@blur="error = null"
|
||||||
@input="validate" />
|
@input="validate()" />
|
||||||
</At>
|
</At>
|
||||||
<input v-tooltip="t('deck', 'Save')"
|
<input v-tooltip="t('deck', 'Save')"
|
||||||
class="icon-confirm"
|
class="icon-confirm"
|
||||||
@@ -99,10 +99,10 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
validate() {
|
validate(submit) {
|
||||||
this.error = null
|
this.error = null
|
||||||
const content = this.contentEditableToParsed()
|
const content = this.contentEditableToParsed()
|
||||||
if (content.length === 0) {
|
if (submit && content.length === 0) {
|
||||||
this.error = t('deck', 'The comment cannot be empty.')
|
this.error = t('deck', 'The comment cannot be empty.')
|
||||||
}
|
}
|
||||||
if (content.length > 1000) {
|
if (content.length > 1000) {
|
||||||
@@ -111,14 +111,13 @@ export default {
|
|||||||
return this.error === null ? content : null
|
return this.error === null ? content : null
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
const content = this.validate()
|
const content = this.validate(true)
|
||||||
if (content) {
|
if (content) {
|
||||||
this.$emit('input', content)
|
this.$emit('input', content)
|
||||||
this.$emit('submit', content)
|
this.$emit('submit', content)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
/* All credits for this go to the talk app
|
||||||
* All credits for this go to the talk app
|
|
||||||
* https://github.com/nextcloud/spreed/blob/e69740b372e17eec4541337b47baa262a5766510/src/components/NewMessageForm/NewMessageForm.vue#L100-L143
|
* https://github.com/nextcloud/spreed/blob/e69740b372e17eec4541337b47baa262a5766510/src/components/NewMessageForm/NewMessageForm.vue#L100-L143
|
||||||
*/
|
*/
|
||||||
contentEditableToParsed() {
|
contentEditableToParsed() {
|
||||||
@@ -133,7 +132,11 @@ export default {
|
|||||||
// adding it.
|
// adding it.
|
||||||
// FIXME user names can contain spaces, in that case they need to be wrapped @"user name" [a-zA-Z0-9\ _\.@\-']+
|
// FIXME user names can contain spaces, in that case they need to be wrapped @"user name" [a-zA-Z0-9\ _\.@\-']+
|
||||||
const mentionValue = mention.firstElementChild.attributes['data-mention-id'].value
|
const mentionValue = mention.firstElementChild.attributes['data-mention-id'].value
|
||||||
mention.replaceWith(' @' + mentionValue + ' ')
|
if (mentionValue.indexOf(' ') !== -1) {
|
||||||
|
mention.replaceWith(' @"' + mentionValue + '" ')
|
||||||
|
} else {
|
||||||
|
mention.replaceWith(' @' + mentionValue + ' ')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return rawToParsed(node.innerHTML)
|
return rawToParsed(node.innerHTML)
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
<li class="comment">
|
<div v-if="reply" class="reply">
|
||||||
|
<span class="reply--hint">{{ t('deck', 'In reply to') }} <UserBubble :user="comment.actorId" :display-name="comment.actorDisplayName" /></span>
|
||||||
|
<RichText class="comment--content"
|
||||||
|
:text="richText(comment)"
|
||||||
|
:arguments="richArgs(comment)"
|
||||||
|
:autolink="true" />
|
||||||
|
</div>
|
||||||
|
<li v-else class="comment">
|
||||||
<template>
|
<template>
|
||||||
<div class="comment--header">
|
<div class="comment--header">
|
||||||
<Avatar :user="comment.actorId" />
|
<Avatar :user="comment.actorId" />
|
||||||
<span class="has-tooltip username">
|
<span class="has-tooltip username">
|
||||||
{{ comment.actorDisplayName }}
|
{{ comment.actorDisplayName }}
|
||||||
</span>
|
</span>
|
||||||
<Actions v-show="canEdit && !edit">
|
<Actions v-show="!edit" :force-menu="true">
|
||||||
<ActionButton icon="icon-rename" @click="showUpdateForm()">
|
<ActionButton icon="icon-reply" @click="replyTo()">
|
||||||
|
{{ t('deck', 'Reply') }}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton v-if="canEdit" icon="icon-rename" @click="showUpdateForm()">
|
||||||
{{ t('deck', 'Update') }}
|
{{ t('deck', 'Update') }}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton icon="icon-delete" @click="deleteComment(comment.id)">
|
<ActionButton v-if="canEdit" icon="icon-delete" @click="deleteComment()">
|
||||||
{{ t('deck', 'Delete') }}
|
{{ t('deck', 'Delete') }}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</Actions>
|
</Actions>
|
||||||
<Actions v-if="edit">
|
<Actions v-if="edit">
|
||||||
<ActionButton icon="icon-close" @click="hideUpdateForm" />
|
<ActionButton icon="icon-close" @click="hideUpdateForm" />
|
||||||
</Actions>
|
</Actions>
|
||||||
|
<div class="spacer" />
|
||||||
|
<div class="timestamp">
|
||||||
|
{{ relativeDate(comment.creationDateTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CommentItem v-if="comment.replyTo" :reply="true" :comment="comment.replyTo" />
|
||||||
|
<div v-show="!edit" ref="richTextElement">
|
||||||
|
<RichText
|
||||||
|
class="comment--content"
|
||||||
|
:text="richText(comment)"
|
||||||
|
:arguments="richArgs(comment)"
|
||||||
|
:autolink="true" />
|
||||||
</div>
|
</div>
|
||||||
<RichText v-show="!edit"
|
|
||||||
ref="richTextElement"
|
|
||||||
class="comment--content"
|
|
||||||
:text="richText"
|
|
||||||
:arguments="richArgs"
|
|
||||||
:autolink="true" />
|
|
||||||
<CommentForm v-if="edit" v-model="commentMsg" @submit="updateComment" />
|
<CommentForm v-if="edit" v-model="commentMsg" @submit="updateComment" />
|
||||||
</template>
|
</template>
|
||||||
</li>
|
</li>
|
||||||
@@ -34,6 +50,8 @@ import { Avatar, Actions, ActionButton, UserBubble } from '@nextcloud/vue'
|
|||||||
import RichText from '@juliushaertl/vue-richtext'
|
import RichText from '@juliushaertl/vue-richtext'
|
||||||
import CommentForm from './CommentForm'
|
import CommentForm from './CommentForm'
|
||||||
import { getCurrentUser } from '@nextcloud/auth'
|
import { getCurrentUser } from '@nextcloud/auth'
|
||||||
|
import md5 from 'blueimp-md5'
|
||||||
|
import relativeDate from '../../mixins/relativeDate'
|
||||||
|
|
||||||
const AtMention = {
|
const AtMention = {
|
||||||
name: 'AtMention',
|
name: 'AtMention',
|
||||||
@@ -52,16 +70,22 @@ export default {
|
|||||||
name: 'CommentItem',
|
name: 'CommentItem',
|
||||||
components: {
|
components: {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
UserBubble,
|
||||||
Actions,
|
Actions,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
CommentForm,
|
CommentForm,
|
||||||
RichText,
|
RichText,
|
||||||
},
|
},
|
||||||
|
mixins: [ relativeDate ],
|
||||||
props: {
|
props: {
|
||||||
comment: {
|
comment: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: undefined,
|
default: undefined,
|
||||||
},
|
},
|
||||||
|
reply: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -75,40 +99,53 @@ export default {
|
|||||||
return this.comment.actorId === getCurrentUser().uid
|
return this.comment.actorId === getCurrentUser().uid
|
||||||
},
|
},
|
||||||
richText() {
|
richText() {
|
||||||
let message = this.parsedMessage
|
return (comment) => {
|
||||||
this.comment.mentions.forEach((mention, index) => {
|
let message = this.parsedMessage(comment.message)
|
||||||
// FIXME: currently only [a-z\-_0-9] are allowed inside of placeholders
|
comment.mentions.forEach((mention, index) => {
|
||||||
message = message.split('@' + mention.mentionId + '').join(`{user-${mention.mentionId}}`)
|
// Currently only [a-z\-_0-9] are allowed inside of placeholders so we use a hash of the mention id as a unique identifier
|
||||||
})
|
const hash = md5(mention.mentionId)
|
||||||
return message
|
message = message.split('@' + mention.mentionId + '').join(`{user-${hash}}`)
|
||||||
|
message = message.split('@"' + mention.mentionId + '"').join(`{user-${hash}}`)
|
||||||
|
|
||||||
|
})
|
||||||
|
return message
|
||||||
|
}
|
||||||
},
|
},
|
||||||
richArgs() {
|
richArgs() {
|
||||||
const mentions = [...this.comment.mentions]
|
return (comment) => {
|
||||||
const result = mentions.reduce(function(result, item, index) {
|
const mentions = [...comment.mentions]
|
||||||
const itemKey = 'user-' + item.mentionId
|
const result = mentions.reduce((result, item, index) => {
|
||||||
result[itemKey] = {
|
const itemKey = 'user-' + md5(item.mentionId)
|
||||||
component: AtMention,
|
result[itemKey] = {
|
||||||
props: {
|
component: AtMention,
|
||||||
user: item.mentionId,
|
props: {
|
||||||
displayName: item.mentionDisplayName,
|
user: item.mentionId,
|
||||||
},
|
displayName: item.mentionDisplayName,
|
||||||
}
|
},
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
return result
|
return result
|
||||||
}, {})
|
}
|
||||||
return result
|
|
||||||
},
|
},
|
||||||
parsedMessage() {
|
parsedMessage() {
|
||||||
const div = document.createElement('div')
|
return (message) => {
|
||||||
div.innerHTML = this.comment.message
|
const div = document.createElement('div')
|
||||||
return (div.textContent || div.innerText || '')
|
div.innerHTML = message
|
||||||
|
return (div.textContent || div.innerText || '')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
replyTo() {
|
||||||
|
this.$store.dispatch('setReplyTo', this.comment)
|
||||||
|
},
|
||||||
showUpdateForm() {
|
showUpdateForm() {
|
||||||
this.commentMsg = this.$refs.richTextElement.$el.innerHTML
|
|
||||||
this.edit = true
|
this.edit = true
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.commentMsg = this.$refs.richTextElement.children[0].innerHTML
|
||||||
|
})
|
||||||
},
|
},
|
||||||
hideUpdateForm() {
|
hideUpdateForm() {
|
||||||
this.commentMsg = ''
|
this.commentMsg = ''
|
||||||
@@ -117,16 +154,16 @@ export default {
|
|||||||
async updateComment() {
|
async updateComment() {
|
||||||
const data = {
|
const data = {
|
||||||
comment: this.commentMsg,
|
comment: this.commentMsg,
|
||||||
cardId: this.comment.cardId,
|
cardId: this.comment.objectId,
|
||||||
commentId: this.comment.id,
|
id: this.comment.id,
|
||||||
}
|
}
|
||||||
await this.$store.dispatch('updateComment', data)
|
await this.$store.dispatch('updateComment', data)
|
||||||
this.hideUpdateForm()
|
this.hideUpdateForm()
|
||||||
},
|
},
|
||||||
deleteComment(commentId) {
|
deleteComment() {
|
||||||
const data = {
|
const data = {
|
||||||
commentId: commentId,
|
id: this.comment.id,
|
||||||
cardId: this.comment.cardId,
|
cardId: this.comment.objectId,
|
||||||
}
|
}
|
||||||
this.$store.dispatch('deleteComment', data)
|
this.$store.dispatch('deleteComment', data)
|
||||||
},
|
},
|
||||||
@@ -137,6 +174,27 @@ export default {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@import "../../css/comments";
|
@import "../../css/comments";
|
||||||
|
|
||||||
|
.reply {
|
||||||
|
border-left: 3px solid var(--color-primary-element);
|
||||||
|
padding-left: 5px;
|
||||||
|
margin-left: 2px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
&::v-deep .rich-text--wrapper {
|
||||||
|
margin-top: -3px;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply--hint {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--color-text-lighter);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment--content {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
.comment--content::v-deep a {
|
.comment--content::v-deep a {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,14 @@
|
|||||||
color: var(--color-text-light);
|
color: var(--color-text-light);
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
flex-grow: 1;
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.timestamp {
|
||||||
|
color: var(--color-text-maxcontrast);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment--content {
|
.comment--content {
|
||||||
|
|||||||
37
src/mixins/relativeDate.js
Normal file
37
src/mixins/relativeDate.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
|
||||||
|
*
|
||||||
|
* @author Julius Härtl <jus@bitgrid.net>
|
||||||
|
*
|
||||||
|
* @license GNU AGPL version 3 or any later version
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import moment from '@nextcloud/moment'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
computed: {
|
||||||
|
relativeDate() {
|
||||||
|
return (timestamp) => {
|
||||||
|
const diff = moment(this.$root.time).diff(moment(timestamp))
|
||||||
|
if (diff >= 0 && diff < 45000) {
|
||||||
|
return t('core', 'seconds ago')
|
||||||
|
}
|
||||||
|
return moment(timestamp).fromNow()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from '@nextcloud/axios'
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
import './../models'
|
import './../models'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,14 +31,14 @@ export class BoardApi {
|
|||||||
|
|
||||||
url(url) {
|
url(url) {
|
||||||
url = `/apps/deck${url}`
|
url = `/apps/deck${url}`
|
||||||
return OC.generateUrl(url)
|
return generateUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a board.
|
* Updates a board.
|
||||||
*
|
*
|
||||||
* @param {Board} board
|
* @param {Board} board the board object to update
|
||||||
* @returns Promise
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
updateBoard(board) {
|
updateBoard(board) {
|
||||||
return axios.put(this.url(`/boards/${board.id}`), board)
|
return axios.put(this.url(`/boards/${board.id}`), board)
|
||||||
@@ -56,10 +57,13 @@ export class BoardApi {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new board.
|
* Creates a new board.
|
||||||
|
* @typedef {Object} BoardCreateObject
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} color
|
||||||
*
|
*
|
||||||
* @param {{String title, String color}} boardData The board data to send.
|
* @param {BoardCreateObject} boardData The board data to send.
|
||||||
* color the hexadecimal color value formated /[0-9A-F]{6}/i
|
* color the hexadecimal color value formated /[0-9A-F]{6}/i
|
||||||
* @return Promise
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
createBoard(boardData) {
|
createBoard(boardData) {
|
||||||
return axios.post(this.url('/boards'), boardData)
|
return axios.post(this.url('/boards'), boardData)
|
||||||
|
|||||||
@@ -21,12 +21,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from '@nextcloud/axios'
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
|
|
||||||
export class CardApi {
|
export class CardApi {
|
||||||
|
|
||||||
url(url) {
|
url(url) {
|
||||||
url = `/apps/deck${url}`
|
url = `/apps/deck${url}`
|
||||||
return OC.generateUrl(url)
|
return generateUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
addCard(card) {
|
addCard(card) {
|
||||||
|
|||||||
@@ -21,8 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from '@nextcloud/axios'
|
import axios from '@nextcloud/axios'
|
||||||
import { getCurrentUser } from '@nextcloud/auth'
|
import { generateOcsUrl } from '@nextcloud/router'
|
||||||
import xmlToTagList from '../helpers/xml'
|
|
||||||
|
|
||||||
export class CommentApi {
|
export class CommentApi {
|
||||||
|
|
||||||
@@ -32,84 +31,30 @@ export class CommentApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadComments({ cardId, limit, offset }) {
|
async loadComments({ cardId, limit, offset }) {
|
||||||
const response = await axios({
|
const api = await axios.get(generateOcsUrl(`apps/deck/api/v1.0/cards`, 2) + `${cardId}/comments`, {
|
||||||
method: 'REPORT',
|
headers: { 'OCS-APIRequest': 'true' },
|
||||||
url: this.url(`${cardId}`),
|
|
||||||
data: `<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<oc:filter-comments xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
|
||||||
<oc:limit>${limit}</oc:limit>
|
|
||||||
<oc:offset>${offset}</oc:offset>
|
|
||||||
</oc:filter-comments>`,
|
|
||||||
})
|
})
|
||||||
return xmlToTagList(response.data)
|
return api.data.ocs.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchComment({ cardId, commentId }) {
|
async createComment({ cardId, comment, replyTo }) {
|
||||||
const response = await axios({
|
const api = await axios.post(generateOcsUrl(`apps/deck/api/v1.0/cards`, 2) + `${cardId}/comments`, {
|
||||||
method: 'PROPFIND',
|
message: `${comment}`,
|
||||||
url: this.url(`${cardId}/${commentId}`),
|
parentId: replyTo ? replyTo.id : null,
|
||||||
data: `<?xml version="1.0"?>
|
|
||||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
|
||||||
<d:prop>
|
|
||||||
<oc:id />
|
|
||||||
<oc:message />
|
|
||||||
<oc:actorType />
|
|
||||||
<oc:actorId />
|
|
||||||
<oc:actorDisplayName />
|
|
||||||
<oc:creationDateTime />
|
|
||||||
<oc:objectType />
|
|
||||||
<oc:objectId />
|
|
||||||
<oc:isUnread />
|
|
||||||
<oc:mentions />
|
|
||||||
</d:prop>
|
|
||||||
</d:propfind>`,
|
|
||||||
})
|
})
|
||||||
return xmlToTagList(response.data)
|
return api.data.ocs.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async createComment({ cardId, comment }) {
|
async updateComment({ cardId, id, comment }) {
|
||||||
const response = await axios({
|
const api = await axios.put(generateOcsUrl(`apps/deck/api/v1.0/cards`, 2) + `${cardId}/comments/${id}`, {
|
||||||
method: 'POST',
|
message: `${comment}`,
|
||||||
url: this.url(`${cardId}`),
|
|
||||||
data: { actorType: 'users', message: `${comment}`, verb: 'comment' },
|
|
||||||
})
|
})
|
||||||
|
return api.data.ocs.data
|
||||||
const header = response.headers['content-location']
|
|
||||||
const headerArray = header.split('/')
|
|
||||||
const id = headerArray[headerArray.length - 1]
|
|
||||||
|
|
||||||
const ret = {
|
|
||||||
cardId: (cardId).toString(),
|
|
||||||
id: id,
|
|
||||||
uId: getCurrentUser().uid,
|
|
||||||
creationDateTime: (new Date()).toString(),
|
|
||||||
message: comment,
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateComment({ cardId, commentId, comment }) {
|
async deleteComment({ cardId, id }) {
|
||||||
const response = await axios({
|
const api = await axios.delete(generateOcsUrl(`apps/deck/api/v1.0/cards`, 2) + `${cardId}/comments/${id}`)
|
||||||
method: 'PROPPATCH',
|
return api.data.ocs.data
|
||||||
url: this.url(`${cardId}/${commentId}`),
|
|
||||||
data: `<?xml version="1.0"?>
|
|
||||||
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
|
||||||
<d:set>
|
|
||||||
<d:prop>
|
|
||||||
<oc:message>${comment}</oc:message>
|
|
||||||
</d:prop>
|
|
||||||
</d:set>
|
|
||||||
</d:propertyupdate>`,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteComment({ cardId, commentId }) {
|
|
||||||
const response = await axios({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: this.url(`${cardId}/${commentId}`),
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async markCommentsAsRead(cardId) {
|
async markCommentsAsRead(cardId) {
|
||||||
|
|||||||
@@ -21,13 +21,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from '@nextcloud/axios'
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
import './../models'
|
import './../models'
|
||||||
|
|
||||||
export class StackApi {
|
export class StackApi {
|
||||||
|
|
||||||
url(url) {
|
url(url) {
|
||||||
url = `/apps/deck${url}`
|
url = `/apps/deck${url}`
|
||||||
return OC.generateUrl(url)
|
return generateUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadStacks(boardId) {
|
loadStacks(boardId) {
|
||||||
@@ -91,7 +92,7 @@ export class StackApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Stack} stack
|
* @param {Stack} stack stack object to create
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
createStack(stack) {
|
createStack(stack) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const COMMENT_FETCH_LIMIT = 10
|
|||||||
export default {
|
export default {
|
||||||
state: {
|
state: {
|
||||||
comments: {},
|
comments: {},
|
||||||
|
replyTo: null,
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
getCommentsForCard: (state) => (id) => {
|
getCommentsForCard: (state) => (id) => {
|
||||||
@@ -52,7 +53,7 @@ export default {
|
|||||||
if (state.comments[cardId] === undefined) {
|
if (state.comments[cardId] === undefined) {
|
||||||
Vue.set(state.comments, cardId, {
|
Vue.set(state.comments, cardId, {
|
||||||
hasMore: comments.length > 0,
|
hasMore: comments.length > 0,
|
||||||
comments,
|
comments: [ ...comments ],
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const newComments = comments.filter((comment) => {
|
const newComments = comments.filter((comment) => {
|
||||||
@@ -78,6 +79,9 @@ export default {
|
|||||||
Vue.set(_comment, 'isUnread', false)
|
Vue.set(_comment, 'isUnread', false)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
setReplyTo(state, comment) {
|
||||||
|
Vue.set(state, 'replyTo', comment)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async fetchComments({ commit }, { cardId, offset }) {
|
async fetchComments({ commit }, { cardId, offset }) {
|
||||||
@@ -103,8 +107,8 @@ export default {
|
|||||||
await dispatch('fetchComments', { cardId, offset: getters.getCommentsForCard(cardId).length })
|
await dispatch('fetchComments', { cardId, offset: getters.getCommentsForCard(cardId).length })
|
||||||
|
|
||||||
},
|
},
|
||||||
async createComment({ commit, dispatch }, { cardId, comment }) {
|
async createComment({ commit, dispatch, state }, { cardId, comment }) {
|
||||||
await apiClient.createComment({ cardId, comment })
|
await apiClient.createComment({ cardId, comment, replyTo: state.replyTo })
|
||||||
await dispatch('fetchComments', { cardId })
|
await dispatch('fetchComments', { cardId })
|
||||||
},
|
},
|
||||||
async deleteComment({ commit }, data) {
|
async deleteComment({ commit }, data) {
|
||||||
@@ -112,13 +116,15 @@ export default {
|
|||||||
commit('deleteComment', data)
|
commit('deleteComment', data)
|
||||||
},
|
},
|
||||||
async updateComment({ commit }, data) {
|
async updateComment({ commit }, data) {
|
||||||
await apiClient.updateComment(data)
|
const comment = await apiClient.updateComment(data)
|
||||||
const commentData = await apiClient.fetchComment(data)
|
await commit('updateComment', { cardId: data.cardId, comment: comment })
|
||||||
await commit('updateComment', { cardId: data.cardId, comment: commentData[0] })
|
|
||||||
},
|
},
|
||||||
async markCommentsAsRead({ commit }, cardId) {
|
async markCommentsAsRead({ commit }, cardId) {
|
||||||
await apiClient.markCommentsAsRead(cardId)
|
await apiClient.markCommentsAsRead(cardId)
|
||||||
await commit('markCommentsAsRead', cardId)
|
await commit('markCommentsAsRead', cardId)
|
||||||
},
|
},
|
||||||
|
setReplyTo({ commit }, comment) {
|
||||||
|
commit('setReplyTo', comment)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user