feat(pinned): Implement federation support for pinned messages

Signed-off-by: Joas Schilling <coding@schilljs.com>
This commit is contained in:
Joas Schilling 2025-11-07 10:46:11 +01:00
parent be7996be4f
commit 3c9828fb34
No known key found for this signature in database
GPG key ID: F72FA5B49FFA96B0
10 changed files with 167 additions and 8 deletions

View file

@ -793,8 +793,6 @@ class ChatManager {
$comment->setMetaData($metaData);
$this->commentsManager->save($comment);
$this->participantService->resetHiddenPinnedId($chat, (int)$comment->getId());
$this->roomService->setLastPinnedId($chat, (int)$comment->getId());
$this->attachmentService->createAttachmentEntryGeneric(

View file

@ -1729,12 +1729,13 @@ class ChatController extends AEnvironmentAwareOCSController {
* @psalm-param non-negative-int $messageId
* @param int $pinUntil Unix timestamp when to unpin the message
* @psalm-param non-negative-int $pinUntil
* @return DataResponse<Http::STATUS_OK, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: 'message'|'until'}, array{}>
* @return DataResponse<Http::STATUS_OK, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: 'message'|'until'|'status'}, array{}>
*
* 200: Message was pinned successfully
* 400: Message could not be pinned
* 404: Message was not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
@ -1744,7 +1745,11 @@ class ChatController extends AEnvironmentAwareOCSController {
'messageId' => '[0-9]+',
])]
public function pinMessage(int $messageId, int $pinUntil = 0): DataResponse {
// FIXME add federation
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
return $proxy->pinMessage($this->room, $this->participant, $messageId, $pinUntil);
}
try {
$comment = $this->chatManager->getComment($this->room, (string)$messageId);
@ -1774,11 +1779,13 @@ class ChatController extends AEnvironmentAwareOCSController {
*
* @param int $messageId ID of the message
* @psalm-param non-negative-int $messageId
* @return DataResponse<Http::STATUS_OK, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'message'}, array{}>
* @return DataResponse<Http::STATUS_OK, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'status'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'message'}, array{}>
*
* 200: Message is not pinned now
* 400: Federation request answered with an unknown status code
* 404: Message was not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
@ -1788,7 +1795,11 @@ class ChatController extends AEnvironmentAwareOCSController {
'messageId' => '[0-9]+',
])]
public function unpinMessage(int $messageId): DataResponse {
// FIXME add federation
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
return $proxy->unpinMessage($this->room, $this->participant, $messageId);
}
try {
$comment = $this->chatManager->getComment($this->room, (string)$messageId);
@ -1813,6 +1824,7 @@ class ChatController extends AEnvironmentAwareOCSController {
* 200: Pinned message is now hidden
* 404: Message was not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]

View file

@ -22,6 +22,7 @@ abstract class ARoomModifiedEvent extends ARoomEvent {
public const PROPERTY_DEFAULT_PERMISSIONS = 'defaultPermissions';
public const PROPERTY_DESCRIPTION = 'description';
public const PROPERTY_IN_CALL = 'inCall';
public const PROPERTY_LAST_PINNED_ID = 'lastPinnedId';
public const PROPERTY_LISTABLE = 'listable';
public const PROPERTY_LOBBY = 'lobby';
public const PROPERTY_LIVE_TRANSCRIPTION_LANGUAGE_ID = 'liveTranscriptionLanguageId';

View file

@ -532,6 +532,7 @@ class BackendNotifier {
ARoomModifiedEvent::PROPERTY_DEFAULT_PERMISSIONS => $room->getDefaultPermissions(),
ARoomModifiedEvent::PROPERTY_DESCRIPTION => $room->getDescription(),
ARoomModifiedEvent::PROPERTY_IN_CALL => $room->getCallFlag(),
ARoomModifiedEvent::PROPERTY_LAST_PINNED_ID => $room->getLastPinnedId(),
ARoomModifiedEvent::PROPERTY_MENTION_PERMISSIONS => $room->getMentionPermissions(),
ARoomModifiedEvent::PROPERTY_MESSAGE_EXPIRATION => $room->getMessageExpiration(),
ARoomModifiedEvent::PROPERTY_NAME => $room->getName(),

View file

@ -408,6 +408,8 @@ class CloudFederationProviderTalk implements ICloudFederationProvider, ISignedCl
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_MENTION_PERMISSIONS) {
/** @psalm-suppress InvalidArgument */
$this->roomService->setMentionPermissions($room, $notification['newValue']);
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_LAST_PINNED_ID) {
$this->roomService->setLastPinnedId($room, $notification['newValue']);
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_MESSAGE_EXPIRATION) {
$this->roomService->setMessageExpiration($room, $notification['newValue']);
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_NAME) {

View file

@ -313,6 +313,85 @@ class ChatController {
return new DataResponse($data, Http::STATUS_OK);
}
/**
* @return DataResponse<Http::STATUS_OK, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: 'message'|'until'|'status'}, array{}>
* @throws CannotReachRemoteException
*
* 200: Message was pinned successfully
* 400: Message could not be pinned
* 404: Message was not found
*
* @see \OCA\Talk\Controller\ChatController::pinMessage()
*/
public function pinMessage(Room $room, Participant $participant, int $messageId, int $pinUntil): DataResponse {
$proxy = $this->proxy->post(
$participant->getAttendee()->getInvitedCloudId(),
$participant->getAttendee()->getAccessToken(),
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/' . $messageId . '/pin',
['pinUntil' => $pinUntil]
);
$statusCode = $proxy->getStatusCode();
if ($statusCode !== Http::STATUS_OK) {
if ($statusCode !== Http::STATUS_NOT_FOUND) {
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
$data = ['error' => 'status'];
return new DataResponse($data, $statusCode);
}
/** @var array{error: 'message'|'until'|'status'} $data */
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_NOT_FOUND, Http::STATUS_BAD_REQUEST]);
return new DataResponse($data, $statusCode);
}
/** @var ?TalkChatMessageWithParent $data */
$data = $this->proxy->getOCSData($proxy, default: null);
if (!empty($data)) {
/** @var TalkChatMessageWithParent $data */
$data = $this->userConverter->convertMessage($room, $data);
}
return new DataResponse($data, Http::STATUS_OK);
}
/**
* @return DataResponse<Http::STATUS_OK, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'status'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'message'}, array{}>
* @throws CannotReachRemoteException
*
* 200: Message is not pinned now
* 400: Federation request answered with an unknown status code
* 404: Message was not found
*
* @see \OCA\Talk\Controller\ChatController::unpinMessage()
*/
public function unpinMessage(Room $room, Participant $participant, int $messageId): DataResponse {
$proxy = $this->proxy->delete(
$participant->getAttendee()->getInvitedCloudId(),
$participant->getAttendee()->getAccessToken(),
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/' . $messageId . '/pin',
);
$statusCode = $proxy->getStatusCode();
if ($statusCode !== Http::STATUS_OK) {
if ($statusCode !== Http::STATUS_NOT_FOUND) {
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
$data = ['error' => 'status'];
return new DataResponse($data, $statusCode);
}
/** @var array{error: 'message'} $data */
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_NOT_FOUND]);
return new DataResponse($data, Http::STATUS_NOT_FOUND);
}
/** @var ?TalkChatMessageWithParent $data */
$data = $this->proxy->getOCSData($proxy, default: null);
if (!empty($data)) {
/** @var TalkChatMessageWithParent $data */
$data = $this->userConverter->convertMessage($room, $data);
}
return new DataResponse($data, Http::STATUS_OK);
}
/**
* @return DataResponse<Http::STATUS_OK|Http::STATUS_ACCEPTED, TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_METHOD_NOT_ALLOWED|Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array{error: string}, array{}>
* @throws CannotReachRemoteException

View file

@ -54,6 +54,7 @@ class RoomModifiedListener implements IEventListener {
ARoomModifiedEvent::PROPERTY_DEFAULT_PERMISSIONS,
ARoomModifiedEvent::PROPERTY_DESCRIPTION,
ARoomModifiedEvent::PROPERTY_IN_CALL,
ARoomModifiedEvent::PROPERTY_LAST_PINNED_ID,
ARoomModifiedEvent::PROPERTY_LOBBY,
ARoomModifiedEvent::PROPERTY_MENTION_PERMISSIONS,
ARoomModifiedEvent::PROPERTY_MESSAGE_EXPIRATION,

View file

@ -230,10 +230,13 @@ class ProxyRequest {
}
/**
* @template T of array<empty>|null
* @param list<int> $allowedStatusCodes
* @param T $default
* @return array|T
* @throws CannotReachRemoteException
*/
public function getOCSData(IResponse $response, array $allowedStatusCodes = [Http::STATUS_OK]): array {
public function getOCSData(IResponse $response, array $allowedStatusCodes = [Http::STATUS_OK], ?array $default = []): ?array {
if (!in_array($response->getStatusCode(), $allowedStatusCodes, true)) {
$this->logUnexpectedStatusCode(__METHOD__, $response->getStatusCode());
}
@ -249,6 +252,6 @@ class ProxyRequest {
throw new CannotReachRemoteException('Error parsing JSON response', $e->getCode(), $e);
}
return $responseData['ocs']['data'] ?? [];
return $responseData['ocs']['data'] ?? $default;
}
}

View file

@ -883,6 +883,9 @@ class RoomService {
}
public function setLastPinnedId(Room $room, int $lastPinnedId): void {
$event = new BeforeRoomModifiedEvent($room, ARoomModifiedEvent::PROPERTY_LAST_PINNED_ID, $lastPinnedId);
$this->dispatcher->dispatchTyped($event);
$update = $this->db->getQueryBuilder();
$update->update('talk_rooms')
->set('last_pinned_id', $update->createNamedParameter($lastPinnedId))
@ -890,6 +893,10 @@ class RoomService {
$update->executeStatement();
$room->setLastPinnedId($lastPinnedId);
$this->participantService->resetHiddenPinnedId($room, $lastPinnedId);
$event = new RoomModifiedEvent($room, ARoomModifiedEvent::PROPERTY_LAST_PINNED_ID, $lastPinnedId);
$this->dispatcher->dispatchTyped($event);
}
public function setAssignedSignalingServer(Room $room, ?int $signalingServer): bool {
@ -1362,6 +1369,10 @@ class RoomService {
$this->logger->error('An error (' . $e->getReason() . ') occurred while trying to sync mentionPermissions of ' . $local->getId() . ' to ' . $host['mentionPermissions'], ['exception' => $e]);
}
}
if (isset($host['lastPinnedId']) && $host['lastPinnedId'] !== $local->getLastPinnedId()) {
$this->setLastPinnedId($local, $host['lastPinnedId']);
$changed[] = ARoomModifiedEvent::PROPERTY_LAST_PINNED_ID;
}
if (isset($host['messageExpiration']) && $host['messageExpiration'] !== $local->getMessageExpiration()) {
try {
$this->setMessageExpiration($local, $host['messageExpiration']);

View file

@ -556,3 +556,54 @@ Feature: federation/chat
Then user "participant2" sees the following shared location in room "LOCAL::room" with 200
| room | actorType | actorId | actorDisplayName | message | messageParameters |
| LOCAL::room | federated_users | participant1@{$LOCAL_URL} | participant1-displayname | {object} | "IGNORE" |
Scenario: Pin handling as a federated user
Given user "participant1" creates room "room" (v4)
| roomType | 2 |
| roomName | room |
And user "participant1" adds federated_user "participant2" to room "room" with 200 (v4)
And using server "REMOTE"
And user "participant2" has the following invitations (v1)
| remoteServerUrl | remoteToken | state | inviterCloudId | inviterDisplayName |
| LOCAL | room | 0 | participant1@LOCAL | participant1-displayname |
And user "participant2" accepts invite to room "room" of server "LOCAL" with 200 (v1)
| id | name | type | remoteServer | remoteToken |
| LOCAL::room | room | 2 | LOCAL | room |
Then user "participant2" is participant of the following rooms (v4)
| id | type |
| LOCAL::room | 2 |
And using server "LOCAL"
When user "participant1" sends message "Message 1" to room "room" with 201
When user "participant1" pins message "Message 1" in room "room" with 200
And using server "REMOTE"
Then user "participant2" is participant of the following rooms (v4)
| id | type | lastPinnedId | hiddenPinnedId |
| LOCAL::room | 2 | Message 1 | EMPTY |
When user "participant2" hides pinned message "Message 1" in room "LOCAL::room" with 200
Then user "participant2" is participant of the following rooms (v4)
| id | type | lastPinnedId | hiddenPinnedId |
| LOCAL::room | 2 | Message 1 | Message 1 |
# Unpinning resets lastPinnedId
And using server "LOCAL"
When user "participant1" unpins message "Message 1" in room "room" with 200
And using server "REMOTE"
Then user "participant2" is participant of the following rooms (v4)
| id | type | lastPinnedId | hiddenPinnedId |
| LOCAL::room | 2 | EMPTY | Message 1 |
# Pin temporarily
And using server "LOCAL"
When user "participant1" pins message "Message 1" for 3 seconds in room "room" with 200
And using server "REMOTE"
Then user "participant2" is participant of the following rooms (v4)
| id | type | lastPinnedId | hiddenPinnedId |
| LOCAL::room | 2 | Message 1 | EMPTY |
When wait for 4 seconds
And using server "LOCAL"
And run "OCA\Talk\BackgroundJob\UnpinMessage" background jobs
And using server "REMOTE"
Then user "participant2" is participant of the following rooms (v4)
| id | type | lastPinnedId | hiddenPinnedId |
| LOCAL::room | 2 | EMPTY | EMPTY |