Skip to content
Merged
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
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* 🌉 **Sync with other chat solutions** With [Matterbridge](https://github.com/42wim/matterbridge/) being integrated in Talk, you can easily sync a lot of other chat solutions to Nextcloud Talk and vice-versa.
]]></description>

<version>24.0.0-dev.2</version>
<version>24.0.0-dev.3</version>
<licence>agpl</licence>

<author>Anna Larch</author>
Expand Down
1 change: 1 addition & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,4 @@
* `config => conversations => group-mode` (local) - User selected grouping mode for conversations (`none`, `group-first` or `private-first`)
* `private-reply` - Whether clients can link the original message to a private reply in one-to-one conversations
* `config => attachments => conversation-subfolders` (local) - Whether per-conversation subfolders are used for Talk attachments; when `true` files must be uploaded to `Talk/<ConversationName>-<token>/<DisplayName>-<uid>/` before calling the attachment endpoint
* `conversation-tags` (local) - Whether the user can create custom tags to organize conversations in the sidebar
2 changes: 2 additions & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ class Capabilities implements IPublicCapability {
'scheduled-messages',
'conversation-presets',
'private-reply',
'conversation-tags',
];

public const CONDITIONAL_FEATURES = [
Expand Down Expand Up @@ -164,6 +165,7 @@ class Capabilities implements IPublicCapability {
'sensitive-conversations',
'scheduled-messages',
'conversation-presets',
'conversation-tags',
];

public const LOCAL_CONFIGS = [
Expand Down
201 changes: 201 additions & 0 deletions lib/Controller/ConversationTagController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Controller;

use OCA\Talk\Exceptions\InvalidTagNameException;
use OCA\Talk\Exceptions\TagLimitExceededException;
use OCA\Talk\Exceptions\TagNameAlreadyInUseException;
use OCA\Talk\Exceptions\TagNotCustomException;
use OCA\Talk\Model\ConversationTag;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\ConversationTagService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;

/**
* @psalm-import-type TalkConversationTag from ResponseDefinitions
*/
class ConversationTagController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
protected ConversationTagService $tagService,
protected ?string $userId,
) {
parent::__construct($appName, $request);
}

/**
* Get all conversation tags for the current user
*
* Required capability: `conversation-tags`
*
* @return DataResponse<Http::STATUS_OK, list<TalkConversationTag>, array{}>
*
* 200: Tags returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/tags', requirements: [
'apiVersion' => '(v4)',
])]
public function getTags(): DataResponse {
$tags = $this->tagService->getTags($this->userId);
return new DataResponse(array_map([$this, 'formatTag'], $tags));
}

/**
* Create a new conversation tag
*
* Required capability: `conversation-tags`
*
* @param string $name Name of the tag
* @return DataResponse<Http::STATUS_CREATED, TalkConversationTag, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'name'|'limit'}, array{}>
*
* 201: Tag created
* 400: Invalid or duplicate name, or the user has reached the tag limit
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/tags', requirements: [
'apiVersion' => '(v4)',
])]
public function createTag(string $name): DataResponse {
try {
$tag = $this->tagService->createTag($this->userId, $name);
} catch (InvalidTagNameException|TagNameAlreadyInUseException) {
return new DataResponse(['error' => 'name'], Http::STATUS_BAD_REQUEST);
} catch (TagLimitExceededException) {
return new DataResponse(['error' => 'limit'], Http::STATUS_BAD_REQUEST);
}
return new DataResponse($this->formatTag($tag), Http::STATUS_CREATED);
}

/**
* Update a conversation tag
*
* Required capability: `conversation-tags`
*
* @param string $tagId ID of the tag
* @param string $name New name for the tag
* @return DataResponse<Http::STATUS_OK, TalkConversationTag, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'name'|'type'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Tag updated
* 400: Invalid or duplicate name, or the tag is a built-in and cannot be renamed
* 404: Tag not found
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/tags/{tagId}', requirements: [
'apiVersion' => '(v4)',
'tagId' => '\d+',
])]
public function updateTag(string $tagId, string $name): DataResponse {
try {
$tag = $this->tagService->updateTag($tagId, $this->userId, $name);
return new DataResponse($this->formatTag($tag));
} catch (DoesNotExistException) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
} catch (InvalidTagNameException|TagNameAlreadyInUseException) {
return new DataResponse(['error' => 'name'], Http::STATUS_BAD_REQUEST);
} catch (TagNotCustomException) {
return new DataResponse(['error' => 'type'], Http::STATUS_BAD_REQUEST);
}
}

/**
* Delete a conversation tag
*
* Required capability: `conversation-tags`
*
* @param string $tagId ID of the tag
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'type'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Tag deleted
* 400: The tag is a built-in and cannot be deleted
* 404: Tag not found
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/tags/{tagId}', requirements: [
'apiVersion' => '(v4)',
'tagId' => '\d+',
])]
public function deleteTag(string $tagId): DataResponse {
try {
$this->tagService->deleteTag($tagId, $this->userId);
return new DataResponse(null);
} catch (DoesNotExistException) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
} catch (TagNotCustomException) {
return new DataResponse(['error' => 'type'], Http::STATUS_BAD_REQUEST);
}
}

/**
* Reorder conversation tags
*
* Required capability: `conversation-tags`
*
* @param list<string> $orderedIds Ordered list of tag IDs
* @return DataResponse<Http::STATUS_OK, list<TalkConversationTag>, array{}>
*
* 200: Tags reordered
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/tags/reorder', requirements: [
'apiVersion' => '(v4)',
])]
public function reorderTags(array $orderedIds): DataResponse {
$this->tagService->reorderTags($this->userId, $orderedIds);
$tags = $this->tagService->getTags($this->userId);
return new DataResponse(array_map([$this, 'formatTag'], $tags));
}

/**
* Set the collapsed state of a conversation tag
*
* Required capability: `conversation-tags`
*
* @param string $tagId ID of the tag
* @param bool $collapsed Whether the tag should be collapsed
* @return DataResponse<Http::STATUS_OK, TalkConversationTag, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Collapsed state updated
* 404: Tag not found
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/tags/{tagId}/collapsed', requirements: [
'apiVersion' => '(v4)',
'tagId' => '\d+',
])]
public function updateTagCollapsed(string $tagId, bool $collapsed): DataResponse {
try {
$tag = $this->tagService->setCollapsed($tagId, $this->userId, $collapsed);
return new DataResponse($this->formatTag($tag));
} catch (DoesNotExistException) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
}

/**
* @return TalkConversationTag
*/
protected function formatTag(ConversationTag $tag): array {
return [
'id' => (string)$tag->getId(),
'name' => $tag->getName(),
'sortOrder' => $tag->getSortOrder(),
'collapsed' => $tag->isCollapsed(),
'type' => $tag->getType(),
];
}
}
25 changes: 25 additions & 0 deletions lib/Controller/RoomController.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
use OCA\Talk\Service\BanService;
use OCA\Talk\Service\BreakoutRoomService;
use OCA\Talk\Service\ChecksumVerificationService;
use OCA\Talk\Service\ConversationTagService;
use OCA\Talk\Service\InvitationService;
use OCA\Talk\Service\NoteToSelfService;
use OCA\Talk\Service\ParticipantService;
Expand Down Expand Up @@ -159,6 +160,7 @@ public function __construct(
protected IURLGenerator $url,
protected IL10N $l,
protected ThreadService $threadService,
protected ConversationTagService $conversationTagService,
protected Forced $forcedParameters,
) {
parent::__construct($appName, $request);
Expand Down Expand Up @@ -1888,6 +1890,29 @@ public function unarchiveConversation(): DataResponse {
return new DataResponse($this->formatRoom($this->room, $this->participant));
}

/**
* Assign conversation tags
*
* Required capability: `conversation-tags`
*
* @param list<string> $tagIds IDs of tags to assign (empty array to unassign all)
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
*
* 200: Conversation tags updated
*/
#[NoAdminRequired]
#[FederationSupported]
#[RequireLoggedInParticipant]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/tags', requirements: [
'apiVersion' => '(v4)',
'token' => '[a-z0-9]{4,30}',
])]
public function assignTags(array $tagIds = []): DataResponse {
$tagIds = $this->conversationTagService->validateTagIdsForUser($this->participant->getAttendee()->getActorId(), $tagIds);
$this->participantService->assignConversationToTags($this->participant, $tagIds);
return new DataResponse($this->formatRoom($this->room, $this->participant));
}

/**
* Mark a conversation as important (still sending notifications while on DND)
*
Expand Down
13 changes: 13 additions & 0 deletions lib/Exceptions/InvalidTagNameException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Exceptions;

class InvalidTagNameException extends \Exception {
}
13 changes: 13 additions & 0 deletions lib/Exceptions/TagLimitExceededException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Exceptions;

class TagLimitExceededException extends \Exception {
}
13 changes: 13 additions & 0 deletions lib/Exceptions/TagNameAlreadyInUseException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Exceptions;

class TagNameAlreadyInUseException extends \Exception {
}
13 changes: 13 additions & 0 deletions lib/Exceptions/TagNotCustomException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Exceptions;

class TagNotCustomException extends \Exception {
}
Loading
Loading