From 49705254cf38f838d06d94b2eac5f58bc78d74a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Mon, 19 Dec 2022 01:41:02 +0100 Subject: [PATCH 1/9] Extract expected URL to its own variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- tests/php/Signaling/BackendNotifierTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/php/Signaling/BackendNotifierTest.php b/tests/php/Signaling/BackendNotifierTest.php index 56eeb8203fb..a711fcf4461 100644 --- a/tests/php/Signaling/BackendNotifierTest.php +++ b/tests/php/Signaling/BackendNotifierTest.php @@ -208,9 +208,11 @@ private function validateBackendRequest($expectedUrl, $request) { } private function assertMessageWasSent(Room $room, array $message): void { + $expectedUrl = $this->baseUrl . '/api/v1/room/' . $room->getToken(); + $requests = $this->controller->getRequests(); - $bodies = array_map(function ($request) use ($room) { - return json_decode($this->validateBackendRequest($this->baseUrl . '/api/v1/room/' . $room->getToken(), $request), true); + $bodies = array_map(function ($request) use ($expectedUrl) { + return json_decode($this->validateBackendRequest($expectedUrl, $request), true); }, $requests); $bodies = array_filter($bodies, function (array $body) use ($message) { From ad21f0325288731f2eac3a45fc2c32b60bea6ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Mon, 19 Dec 2022 01:41:30 +0100 Subject: [PATCH 2/9] Ignore messages in other rooms when asserting that a message was sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When asserting that a message was sent to the signaling server all the requests were validated before looking for the expected one. As the requests include the token of the room all the requests were expected to be sent to the same room; otherwise any request sent to other room would make the assert fail. Now the requests to other rooms than the room of the actual message being asserted are ignored, which will make possible to sent messages to different rooms in the same test. Signed-off-by: Daniel Calviño Sánchez --- tests/php/Signaling/BackendNotifierTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/php/Signaling/BackendNotifierTest.php b/tests/php/Signaling/BackendNotifierTest.php index a711fcf4461..3a649cc5a0f 100644 --- a/tests/php/Signaling/BackendNotifierTest.php +++ b/tests/php/Signaling/BackendNotifierTest.php @@ -211,6 +211,9 @@ private function assertMessageWasSent(Room $room, array $message): void { $expectedUrl = $this->baseUrl . '/api/v1/room/' . $room->getToken(); $requests = $this->controller->getRequests(); + $requests = array_filter($requests, function ($request) use ($expectedUrl) { + return $request['url'] === $expectedUrl; + }); $bodies = array_map(function ($request) use ($expectedUrl) { return json_decode($this->validateBackendRequest($expectedUrl, $request), true); }, $requests); From 62fd48d1a764c24d731e24b9289917c6e2c9bdc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Mon, 19 Dec 2022 01:43:01 +0100 Subject: [PATCH 3/9] Send signaling message when starting or stopping breakout rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the breakout room status changes a "switchto" message is sent to all the active sessions in either the parent or the breakout rooms (depending on whether they are being started or stopped) with the token of the room that they have to switch to. When the breakout rooms are started the message is sent only to non moderators, as moderators do not have a single breakout room assigned. On the other hand, when the breakout rooms are stopped the message is also sent to all moderators (who are in a breakout room and not already in the parent room), as all participants need to switch to the parent room. Signed-off-by: Daniel Calviño Sánchez --- lib/Signaling/BackendNotifier.php | 27 +++ lib/Signaling/Listener.php | 81 +++++++ tests/php/Signaling/BackendNotifierTest.php | 254 ++++++++++++++++++++ 3 files changed, 362 insertions(+) diff --git a/lib/Signaling/BackendNotifier.php b/lib/Signaling/BackendNotifier.php index 139107a9d6a..577cc7645c9 100644 --- a/lib/Signaling/BackendNotifier.php +++ b/lib/Signaling/BackendNotifier.php @@ -280,6 +280,33 @@ public function roomDeleted(Room $room, array $userIds): void { ]); } + /** + * The given participants should switch to the given room. + * + * @param Room $room + * @param string $switchToRoomToken + * @param string[] $sessionIds + * @throws \Exception + */ + public function switchToRoom(Room $room, string $switchToRoomToken, array $sessionIds): void { + $start = microtime(true); + $this->backendRequest($room, [ + 'type' => 'switchto', + 'switchto' => [ + 'roomid' => $switchToRoomToken, + 'sessions' => $sessionIds, + ], + ]); + $duration = microtime(true) - $start; + $this->logger->debug('Switch to room: {token} {roomid} {sessions} ({duration})', [ + 'token' => $room->getToken(), + 'roomid' => $switchToRoomToken, + 'sessions' => print_r($sessionIds, true), + 'duration' => sprintf('%.2f', $duration), + 'app' => 'spreed-hpb', + ]); + } + /** * The participant list of the given room has been modified. * diff --git a/lib/Signaling/Listener.php b/lib/Signaling/Listener.php index 7087d49d9f6..2e11b9447b4 100644 --- a/lib/Signaling/Listener.php +++ b/lib/Signaling/Listener.php @@ -37,6 +37,8 @@ use OCA\Talk\Events\RemoveUserEvent; use OCA\Talk\Events\RoomEvent; use OCA\Talk\GuestManager; +use OCA\Talk\Manager; +use OCA\Talk\Model\BreakoutRoom; use OCA\Talk\Participant; use OCA\Talk\Room; use OCA\Talk\Service\ParticipantService; @@ -98,6 +100,7 @@ protected static function registerExternalSignaling(IEventDispatcher $dispatcher $dispatcher->addListener(Room::EVENT_AFTER_END_CALL_FOR_EVERYONE, [self::class, 'sendEndCallForEveryone']); $dispatcher->addListener(Room::EVENT_AFTER_GUESTS_CLEAN, [self::class, 'notifyParticipantsAfterGuestClean']); $dispatcher->addListener(Room::EVENT_AFTER_SET_CALL_RECORDING, [self::class, 'sendSignalingMessageWhenToggleRecording']); + $dispatcher->addListener(Room::EVENT_AFTER_SET_BREAKOUT_ROOM_STATUS, [self::class, 'notifyParticipantsAfterSetBreakoutRoomStatus']); $dispatcher->addListener(GuestManager::EVENT_AFTER_NAME_UPDATE, [self::class, 'notifyParticipantsAfterNameUpdated']); $dispatcher->addListener(ChatManager::EVENT_AFTER_MESSAGE_SEND, [self::class, 'notifyUsersViaExternalSignalingToRefreshTheChat']); $dispatcher->addListener(ChatManager::EVENT_AFTER_SYSTEM_MESSAGE_SEND, [self::class, 'notifyUsersViaExternalSignalingToRefreshTheChat']); @@ -324,6 +327,84 @@ public static function notifyParticipantsAfterGuestClean(RoomEvent $event): void $notifier->participantsModified($event->getRoom(), $sessionIds); } + public static function notifyParticipantsAfterSetBreakoutRoomStatus(RoomEvent $event): void { + if (self::isUsingInternalSignaling()) { + return; + } + + $room = $event->getRoom(); + if ($room->getBreakoutRoomStatus() === BreakoutRoom::STATUS_STARTED) { + self::notifyParticipantsAfterBreakoutRoomStarted($room); + } else { + self::notifyParticipantsAfterBreakoutRoomStopped($room); + } + } + + private static function notifyParticipantsAfterBreakoutRoomStarted(Room $room): void { + $manager = Server::get(Manager::class); + $breakoutRooms = $manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $room->getToken()); + + $switchToData = []; + + $participantService = Server::get(ParticipantService::class); + $parentRoomParticipants = $participantService->getSessionsAndParticipantsForRoom($room); + + $notifier = Server::get(BackendNotifier::class); + + foreach ($breakoutRooms as $breakoutRoom) { + $sessionIds = []; + + $breakoutRoomParticipants = $participantService->getParticipantsForRoom($breakoutRoom); + foreach ($breakoutRoomParticipants as $breakoutRoomParticipant) { + foreach (self::getSessionIdsForNonModeratorsMatchingParticipant($breakoutRoomParticipant, $parentRoomParticipants) as $sessionId) { + $sessionIds[] = $sessionId; + } + } + + $notifier->switchToRoom($room, $breakoutRoom->getToken(), $sessionIds); + } + } + + private static function getSessionIdsForNonModeratorsMatchingParticipant(Participant $targetParticipant, array $participants) { + $sessionIds = []; + + foreach ($participants as $participant) { + if ($participant->getAttendee()->getActorType() === $targetParticipant->getAttendee()->getActorType() && + $participant->getAttendee()->getActorId() === $targetParticipant->getAttendee()->getActorId() && + !$participant->hasModeratorPermissions()) { + $session = $participant->getSession(); + if ($session) { + $sessionIds[] = $session->getSessionId(); + } + } + } + + return $sessionIds; + } + + private static function notifyParticipantsAfterBreakoutRoomStopped(Room $room): void { + $manager = Server::get(Manager::class); + $breakoutRooms = $manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $room->getToken()); + + $participantService = Server::get(ParticipantService::class); + + $notifier = Server::get(BackendNotifier::class); + + foreach ($breakoutRooms as $breakoutRoom) { + $sessionIds = []; + + $participants = $participantService->getSessionsAndParticipantsForRoom($breakoutRoom); + foreach ($participants as $participant) { + $session = $participant->getSession(); + if ($session) { + $sessionIds[] = $session->getSessionId(); + } + } + + $notifier->switchToRoom($breakoutRoom, $room->getToken(), $sessionIds); + } + } + public static function notifyParticipantsAfterNameUpdated(ModifyParticipantEvent $event): void { if (self::isUsingInternalSignaling()) { return; diff --git a/tests/php/Signaling/BackendNotifierTest.php b/tests/php/Signaling/BackendNotifierTest.php index 3a649cc5a0f..5c25a866986 100644 --- a/tests/php/Signaling/BackendNotifierTest.php +++ b/tests/php/Signaling/BackendNotifierTest.php @@ -23,15 +23,18 @@ namespace OCA\Talk\Tests\php\Signaling; use OCA\Talk\AppInfo\Application; +use OCA\Talk\Chat\ChatManager; use OCA\Talk\Chat\CommentsManager; use OCA\Talk\Config; use OCA\Talk\Events\SignalingRoomPropertiesEvent; use OCA\Talk\Manager; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\AttendeeMapper; +use OCA\Talk\Model\BreakoutRoom; use OCA\Talk\Model\SessionMapper; use OCA\Talk\Participant; use OCA\Talk\Room; +use OCA\Talk\Service\BreakoutRoomService; use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\RoomService; use OCA\Talk\Signaling\BackendNotifier; @@ -47,6 +50,7 @@ use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; +use OCP\Notification\IManager as INotificationManager; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; use OCP\Share\IManager; @@ -91,6 +95,7 @@ class BackendNotifierTest extends TestCase { private ?Manager $manager = null; private ?RoomService $roomService = null; + private ?BreakoutRoomService $breakoutRoomService = null; private ?string $userId = null; private ?string $signalingSecret = null; @@ -167,6 +172,24 @@ public function setUp(): void { $this->dispatcher, $this->jobList ); + + $l = $this->createMock(IL10N::class); + $l->expects($this->any()) + ->method('t') + ->willReturnCallback(function ($text, $parameters = []) { + return vsprintf($text, $parameters); + }); + + $this->breakoutRoomService = new BreakoutRoomService( + $this->config, + $this->manager, + $this->roomService, + $this->participantService, + $this->createMock(ChatManager::class), + $this->createMock(INotificationManager::class), + $this->dispatcher, + $l + ); } public function tearDown(): void { @@ -259,6 +282,11 @@ private function sortParticipantUsers(array $message): array { ; }); } + if ($message['type'] === 'switchto') { + usort($message['switchto']['sessions'], static function ($a, $b) { + return $a <=> $b; + }); + } return $message; } @@ -1160,4 +1188,230 @@ public function testParticipantsTypeChanged() { ], ]); } + + public function testBreakoutRoomStart() { + $room = $this->manager->createRoom(Room::TYPE_GROUP); + $this->participantService->addUsers($room, [ + [ + 'actorType' => 'users', + 'actorId' => 'userId1', + ], + [ + 'actorType' => 'users', + 'actorId' => 'userId2', + ], + [ + 'actorType' => 'users', + 'actorId' => 'userId3', + ], + [ + 'actorType' => 'users', + 'actorId' => 'userIdModerator1', + ], + ]); + + /** @var IUser|MockObject $user1 */ + $user1 = $this->createMock(IUser::class); + $user1->expects($this->any()) + ->method('getUID') + ->willReturn('userId1'); + + /** @var IUser|MockObject $user2 */ + $user2 = $this->createMock(IUser::class); + $user2->expects($this->any()) + ->method('getUID') + ->willReturn('userId2'); + + /** @var IUser|MockObject $user3 */ + $user3 = $this->createMock(IUser::class); + $user3->expects($this->any()) + ->method('getUID') + ->willReturn('userId3'); + + /** @var IUser|MockObject $userModerator1 */ + $userModerator1 = $this->createMock(IUser::class); + $userModerator1->expects($this->any()) + ->method('getUID') + ->willReturn('userIdModerator1'); + + $roomService = $this->createMock(RoomService::class); + $roomService->method('verifyPassword') + ->willReturn(['result' => true, 'url' => '']); + + $participant1 = $this->participantService->joinRoom($roomService, $room, $user1, ''); + $sessionId1 = $participant1->getSession()->getSessionId(); + $participant1 = $this->participantService->getParticipantBySession($room, $sessionId1); + + $participant2 = $this->participantService->joinRoom($roomService, $room, $user2, ''); + $sessionId2 = $participant2->getSession()->getSessionId(); + $participant2 = $this->participantService->getParticipantBySession($room, $sessionId2); + + $participant3 = $this->participantService->joinRoom($roomService, $room, $user3, ''); + $sessionId3 = $participant3->getSession()->getSessionId(); + $participant3 = $this->participantService->getParticipantBySession($room, $sessionId3); + + $participant3b = $this->participantService->joinRoom($roomService, $room, $user3, ''); + $sessionId3b = $participant3b->getSession()->getSessionId(); + $participant3b = $this->participantService->getParticipantBySession($room, $sessionId3b); + + $participantModerator1 = $this->participantService->joinRoom($roomService, $room, $userModerator1, ''); + $sessionIdModerator1 = $participantModerator1->getSession()->getSessionId(); + $participantModerator1 = $this->participantService->getParticipantBySession($room, $sessionIdModerator1); + + $this->participantService->updateParticipantType($room, $participantModerator1, Participant::MODERATOR); + + $attendeeMap = []; + $attendeeMap[$participant1->getSession()->getAttendeeId()] = 0; + $attendeeMap[$participant2->getSession()->getAttendeeId()] = 1; + $attendeeMap[$participant3->getSession()->getAttendeeId()] = 0; + $attendeeMap[$participantModerator1->getSession()->getAttendeeId()] = 0; + + $breakoutRooms = $this->breakoutRoomService->setupBreakoutRooms($room, BreakoutRoom::MODE_MANUAL, 2, json_encode($attendeeMap)); + + $this->controller->clearRequests(); + + $this->breakoutRoomService->startBreakoutRooms($room); + + $this->assertMessageWasSent($room, [ + 'type' => 'switchto', + 'switchto' => [ + 'roomid' => $breakoutRooms[0]->getToken(), + 'sessions' => [ + $sessionId1, + $sessionId3, + $sessionId3b, + ], + ], + ]); + + $this->assertMessageWasSent($room, [ + 'type' => 'switchto', + 'switchto' => [ + 'roomid' => $breakoutRooms[1]->getToken(), + 'sessions' => [ + $sessionId2, + ], + ], + ]); + } + + public function testBreakoutRoomStop() { + $room = $this->manager->createRoom(Room::TYPE_GROUP); + $this->participantService->addUsers($room, [ + [ + 'actorType' => 'users', + 'actorId' => 'userId1', + ], + [ + 'actorType' => 'users', + 'actorId' => 'userId2', + ], + [ + 'actorType' => 'users', + 'actorId' => 'userId3', + ], + [ + 'actorType' => 'users', + 'actorId' => 'userIdModerator1', + ], + ]); + + /** @var IUser|MockObject $user1 */ + $user1 = $this->createMock(IUser::class); + $user1->expects($this->any()) + ->method('getUID') + ->willReturn('userId1'); + + /** @var IUser|MockObject $user2 */ + $user2 = $this->createMock(IUser::class); + $user2->expects($this->any()) + ->method('getUID') + ->willReturn('userId2'); + + /** @var IUser|MockObject $user3 */ + $user3 = $this->createMock(IUser::class); + $user3->expects($this->any()) + ->method('getUID') + ->willReturn('userId3'); + + /** @var IUser|MockObject $userModerator1 */ + $userModerator1 = $this->createMock(IUser::class); + $userModerator1->expects($this->any()) + ->method('getUID') + ->willReturn('userIdModerator1'); + + $roomService = $this->createMock(RoomService::class); + $roomService->method('verifyPassword') + ->willReturn(['result' => true, 'url' => '']); + + $participant1 = $this->participantService->joinRoom($roomService, $room, $user1, ''); + $sessionId1 = $participant1->getSession()->getSessionId(); + $participant1 = $this->participantService->getParticipantBySession($room, $sessionId1); + + $participant2 = $this->participantService->joinRoom($roomService, $room, $user2, ''); + $sessionId2 = $participant2->getSession()->getSessionId(); + $participant2 = $this->participantService->getParticipantBySession($room, $sessionId2); + + $participant3 = $this->participantService->joinRoom($roomService, $room, $user3, ''); + $sessionId3 = $participant3->getSession()->getSessionId(); + $participant3 = $this->participantService->getParticipantBySession($room, $sessionId3); + + $participantModerator1 = $this->participantService->joinRoom($roomService, $room, $userModerator1, ''); + $sessionIdModerator1 = $participantModerator1->getSession()->getSessionId(); + $participantModerator1 = $this->participantService->getParticipantBySession($room, $sessionIdModerator1); + + $this->participantService->updateParticipantType($room, $participantModerator1, Participant::MODERATOR); + + $attendeeMap = []; + $attendeeMap[$participant1->getSession()->getAttendeeId()] = 0; + $attendeeMap[$participant2->getSession()->getAttendeeId()] = 1; + $attendeeMap[$participant3->getSession()->getAttendeeId()] = 0; + $attendeeMap[$participantModerator1->getSession()->getAttendeeId()] = 0; + + $breakoutRooms = $this->breakoutRoomService->setupBreakoutRooms($room, BreakoutRoom::MODE_MANUAL, 2, json_encode($attendeeMap)); + + $this->breakoutRoomService->startBreakoutRooms($room); + + $participant1 = $this->participantService->joinRoom($roomService, $breakoutRooms[0], $user1, ''); + $sessionId1 = $participant1->getSession()->getSessionId(); + + $participant2 = $this->participantService->joinRoom($roomService, $breakoutRooms[1], $user2, ''); + $sessionId2 = $participant2->getSession()->getSessionId(); + + $participant3 = $this->participantService->joinRoom($roomService, $breakoutRooms[0], $user3, ''); + $sessionId3 = $participant3->getSession()->getSessionId(); + + $participant3b = $this->participantService->joinRoom($roomService, $breakoutRooms[0], $user3, ''); + $sessionId3b = $participant3b->getSession()->getSessionId(); + + $participantModerator1 = $this->participantService->joinRoom($roomService, $breakoutRooms[0], $userModerator1, ''); + $sessionIdModerator1 = $participantModerator1->getSession()->getSessionId(); + + $this->controller->clearRequests(); + + $this->breakoutRoomService->stopBreakoutRooms($room); + + $this->assertMessageWasSent($breakoutRooms[0], [ + 'type' => 'switchto', + 'switchto' => [ + 'roomid' => $room->getToken(), + 'sessions' => [ + $sessionId1, + $sessionId3, + $sessionId3b, + $sessionIdModerator1, + ], + ], + ]); + + $this->assertMessageWasSent($breakoutRooms[1], [ + 'type' => 'switchto', + 'switchto' => [ + 'roomid' => $room->getToken(), + 'sessions' => [ + $sessionId2, + ], + ], + ]); + } } From cee13cb8cf10f5a4c9437731732df8a2e10ffcfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Mon, 30 Jan 2023 14:49:55 +0100 Subject: [PATCH 4/9] Do not send "switchto" messages for breakout rooms without sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- lib/Signaling/Listener.php | 8 ++++-- tests/php/Signaling/BackendNotifierTest.php | 30 +++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/Signaling/Listener.php b/lib/Signaling/Listener.php index 2e11b9447b4..a129f3b498e 100644 --- a/lib/Signaling/Listener.php +++ b/lib/Signaling/Listener.php @@ -361,7 +361,9 @@ private static function notifyParticipantsAfterBreakoutRoomStarted(Room $room): } } - $notifier->switchToRoom($room, $breakoutRoom->getToken(), $sessionIds); + if (!empty($sessionIds)) { + $notifier->switchToRoom($room, $breakoutRoom->getToken(), $sessionIds); + } } } @@ -401,7 +403,9 @@ private static function notifyParticipantsAfterBreakoutRoomStopped(Room $room): } } - $notifier->switchToRoom($breakoutRoom, $room->getToken(), $sessionIds); + if (!empty($sessionIds)) { + $notifier->switchToRoom($breakoutRoom, $room->getToken(), $sessionIds); + } } } diff --git a/tests/php/Signaling/BackendNotifierTest.php b/tests/php/Signaling/BackendNotifierTest.php index 5c25a866986..5fbe8049144 100644 --- a/tests/php/Signaling/BackendNotifierTest.php +++ b/tests/php/Signaling/BackendNotifierTest.php @@ -230,6 +230,24 @@ private function validateBackendRequest($expectedUrl, $request) { return $body; } + private function assertMessageCount(Room $room, string $messageType, int $expectedCount): void { + $expectedUrl = $this->baseUrl . '/api/v1/room/' . $room->getToken(); + + $requests = $this->controller->getRequests(); + $requests = array_filter($requests, function ($request) use ($expectedUrl) { + return $request['url'] === $expectedUrl; + }); + $bodies = array_map(function ($request) use ($expectedUrl) { + return json_decode($this->validateBackendRequest($expectedUrl, $request), true); + }, $requests); + + $bodies = array_filter($bodies, function (array $body) use ($messageType) { + return $body['type'] === $messageType; + }); + + $this->assertCount($expectedCount, $bodies, json_encode($bodies, JSON_PRETTY_PRINT)); + } + private function assertMessageWasSent(Room $room, array $message): void { $expectedUrl = $this->baseUrl . '/api/v1/room/' . $room->getToken(); @@ -1260,18 +1278,21 @@ public function testBreakoutRoomStart() { $this->participantService->updateParticipantType($room, $participantModerator1, Participant::MODERATOR); + // Third room is explicitly empty. $attendeeMap = []; $attendeeMap[$participant1->getSession()->getAttendeeId()] = 0; $attendeeMap[$participant2->getSession()->getAttendeeId()] = 1; $attendeeMap[$participant3->getSession()->getAttendeeId()] = 0; $attendeeMap[$participantModerator1->getSession()->getAttendeeId()] = 0; - $breakoutRooms = $this->breakoutRoomService->setupBreakoutRooms($room, BreakoutRoom::MODE_MANUAL, 2, json_encode($attendeeMap)); + $breakoutRooms = $this->breakoutRoomService->setupBreakoutRooms($room, BreakoutRoom::MODE_MANUAL, 3, json_encode($attendeeMap)); $this->controller->clearRequests(); $this->breakoutRoomService->startBreakoutRooms($room); + $this->assertMessageCount($room, 'switchto', 2); + $this->assertMessageWasSent($room, [ 'type' => 'switchto', 'switchto' => [ @@ -1362,13 +1383,14 @@ public function testBreakoutRoomStop() { $this->participantService->updateParticipantType($room, $participantModerator1, Participant::MODERATOR); + // Third room is explicitly empty. $attendeeMap = []; $attendeeMap[$participant1->getSession()->getAttendeeId()] = 0; $attendeeMap[$participant2->getSession()->getAttendeeId()] = 1; $attendeeMap[$participant3->getSession()->getAttendeeId()] = 0; $attendeeMap[$participantModerator1->getSession()->getAttendeeId()] = 0; - $breakoutRooms = $this->breakoutRoomService->setupBreakoutRooms($room, BreakoutRoom::MODE_MANUAL, 2, json_encode($attendeeMap)); + $breakoutRooms = $this->breakoutRoomService->setupBreakoutRooms($room, BreakoutRoom::MODE_MANUAL, 3, json_encode($attendeeMap)); $this->breakoutRoomService->startBreakoutRooms($room); @@ -1391,6 +1413,10 @@ public function testBreakoutRoomStop() { $this->breakoutRoomService->stopBreakoutRooms($room); + $this->assertMessageCount($breakoutRooms[0], 'switchto', 1); + $this->assertMessageCount($breakoutRooms[1], 'switchto', 1); + $this->assertMessageCount($breakoutRooms[2], 'switchto', 0); + $this->assertMessageWasSent($breakoutRooms[0], [ 'type' => 'switchto', 'switchto' => [ From 8479b8a18cd5f0b3782864e37956ec3bc0b5681f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Mon, 19 Dec 2022 01:46:39 +0100 Subject: [PATCH 5/9] Handle "switchto" message in WebUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the client receives a message to switch to a different room the WebUI joins that room. If the WebUI was already in a call it will automatically join the call in the target room; in that case the call view will be kept shown during the switch, rather than showing the chat while leaving the previous call and joining the new room to then show the call again when joining the call in the target room. Signed-off-by: Daniel Calviño Sánchez --- src/App.vue | 34 +++++++++++++++++++++++++++++++++- src/mixins/isInCall.js | 5 +++-- src/store/callViewStore.js | 9 +++++++++ src/utils/signaling.js | 5 +++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/App.vue b/src/App.vue index b06b40ee79a..514a207601a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -65,7 +65,7 @@ import UploadEditor from './components/UploadEditor.vue' import SettingsDialog from './components/SettingsDialog/SettingsDialog.vue' import ConversationSettingsDialog from './components/ConversationSettings/ConversationSettingsDialog.vue' import '@nextcloud/dialogs/styles/toast.scss' -import { CONVERSATION } from './constants.js' +import { CONVERSATION, PARTICIPANT } from './constants.js' import DeviceChecker from './components/DeviceChecker/DeviceChecker.vue' import isMobile from '@nextcloud/vue/dist/Mixins/isMobile.js' @@ -263,6 +263,38 @@ export default { } }) + EventBus.$on('switch-to-conversation', (params) => { + if (this.isInCall) { + this.$store.dispatch('setForceCallView', true) + + EventBus.$once('joined-conversation', async ({ token }) => { + if (params.token !== token) { + return + } + + const conversation = this.$store.getters.conversation(token) + + let flags = PARTICIPANT.CALL_FLAG.IN_CALL + if (conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_AUDIO) { + flags |= PARTICIPANT.CALL_FLAG.WITH_AUDIO + } + if (conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_VIDEO) { + flags |= PARTICIPANT.CALL_FLAG.WITH_VIDEO + } + + await this.$store.dispatch('joinCall', { + token: params.token, + participantIdentifier: this.$store.getters.getParticipantIdentifier(), + flags, + }) + + this.$store.dispatch('setForceCallView', false) + }) + } + + this.$router.push({ name: 'conversation', params: { token: params.token, skipLeaveWarning: true } }) + }) + EventBus.$on('conversations-received', (params) => { if (this.$route.name === 'conversation' && !this.$store.getters.conversation(this.token)) { diff --git a/src/mixins/isInCall.js b/src/mixins/isInCall.js index a5cfa0a0c48..d3c6924f0bb 100644 --- a/src/mixins/isInCall.js +++ b/src/mixins/isInCall.js @@ -35,8 +35,9 @@ export default { computed: { isInCall() { - return this.sessionStorageJoinedConversation === this.$store.getters.getToken() - && this.$store.getters.isInCall(this.$store.getters.getToken()) + return this.$store.getters.forceCallView + || (this.sessionStorageJoinedConversation === this.$store.getters.getToken() + && this.$store.getters.isInCall(this.$store.getters.getToken())) }, }, diff --git a/src/store/callViewStore.js b/src/store/callViewStore.js index 3fcbec3c304..b5abbc21b31 100644 --- a/src/store/callViewStore.js +++ b/src/store/callViewStore.js @@ -27,6 +27,7 @@ import { } from '../constants.js' const state = { + forceCallView: false, isGrid: false, isStripeOpen: true, lastIsGrid: null, @@ -39,6 +40,7 @@ const state = { } const getters = { + forceCallView: (state) => state.forceCallView, isGrid: (state) => state.isGrid, isStripeOpen: (state) => state.isStripeOpen, lastIsGrid: (state) => state.lastIsGrid, @@ -65,6 +67,9 @@ const getters = { const mutations = { + setForceCallView(state, value) { + state.forceCallView = value + }, isGrid(state, value) { state.isGrid = value }, @@ -108,6 +113,10 @@ const mutations = { } const actions = { + setForceCallView(context, value) { + context.commit('setForceCallView', value) + }, + selectedVideoPeerId(context, value) { context.commit('selectedVideoPeerId', value) }, diff --git a/src/utils/signaling.js b/src/utils/signaling.js index ed09af42911..ec6d40ba0bc 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -1275,6 +1275,11 @@ Signaling.Standalone.prototype.processRoomEvent = function(data) { this._trigger('participantListChanged') } break + case 'switchto': + EventBus.$emit('switch-to-conversation', { + token: data.event.switchto.roomid, + }) + break case 'message': this.processRoomMessageEvent(data.event.message.data) break From 0f0c0f5142ce2a44c1b1976893e95e703acb179d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Mon, 30 Jan 2023 17:48:11 +0100 Subject: [PATCH 6/9] Add "switchto" to the required signaling server features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- lib/Signaling/Manager.php | 3 ++- src/utils/signaling.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/Signaling/Manager.php b/lib/Signaling/Manager.php index 87f472900a7..8fb6667a19a 100644 --- a/lib/Signaling/Manager.php +++ b/lib/Signaling/Manager.php @@ -55,7 +55,8 @@ public function isCompatibleSignalingServer(IResponse $response): bool { $features = array_map('trim', $features); return in_array('audio-video-permissions', $features, true) && in_array('incall-all', $features, true) - && in_array('hello-v2', $features, true); + && in_array('hello-v2', $features, true) + && in_array('switchto', $features, true); } public function getSignalingServerLinkForConversation(?Room $room): string { diff --git a/src/utils/signaling.js b/src/utils/signaling.js index ec6d40ba0bc..c45f5c98873 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -1026,7 +1026,7 @@ Signaling.Standalone.prototype.helloResponseReceived = function(data) { } } - if (!this.hasFeature('audio-video-permissions') || !this.hasFeature('incall-all')) { + if (!this.hasFeature('audio-video-permissions') || !this.hasFeature('incall-all') || !this.hasFeature('switchto')) { showError( t('spreed', 'The configured signaling server needs to be updated to be compatible with this version of Talk. Please contact your administrator.'), { From c58be9dcab5fff8a63b94de73b3536caec1d5a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Tue, 31 Jan 2023 04:40:41 +0100 Subject: [PATCH 7/9] Keep previous media state when switching to a call in another room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- src/App.vue | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/App.vue b/src/App.vue index 514a207601a..22f689664e8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -267,11 +267,31 @@ export default { if (this.isInCall) { this.$store.dispatch('setForceCallView', true) + const enableAudio = !localStorage.getItem('audioDisabled_' + this.token) + const enableVideo = !localStorage.getItem('videoDisabled_' + this.token) + const enableVirtualBackground = !!localStorage.getItem('virtualBackgroundEnabled_' + this.token) + EventBus.$once('joined-conversation', async ({ token }) => { if (params.token !== token) { return } + if (enableAudio) { + localStorage.removeItem('audioDisabled_' + token) + } else { + localStorage.setItem('audioDisabled_' + token, 'true') + } + if (enableVideo) { + localStorage.removeItem('videoDisabled_' + token) + } else { + localStorage.setItem('videoDisabled_' + token, 'true') + } + if (enableVirtualBackground) { + localStorage.setItem('virtualBackgroundEnabled_' + token, 'true') + } else { + localStorage.removeItem('virtualBackgroundEnabled_' + token) + } + const conversation = this.$store.getters.conversation(token) let flags = PARTICIPANT.CALL_FLAG.IN_CALL From afde0e0d3a0b21eff34a884901c86047828f943f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Tue, 31 Jan 2023 04:42:44 +0100 Subject: [PATCH 8/9] Update room properties with data from "roomlist" update event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external signaling server includes some room data in the "roomlist" update event sent when a room is modified. That data is up to date, and it will be the same received when fetching the room data again, so the properties can be already updated in the store. This prevents the lobby from being briefly shown when switching to a breakout room due to the "switchto" message being handled before the updated room data could be fetched from the server. In order to keep the changes to a minimum note that this does was not applied to guest users, as a different event seems to be sent in that case, nor to the Talk sidebar, as the current properties provided in the event should not be relevant to it. Signed-off-by: Daniel Calviño Sánchez --- src/components/LeftSidebar/LeftSidebar.vue | 12 ++++++++-- src/store/conversationsStore.js | 11 +++++++++ src/utils/signaling.js | 27 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/components/LeftSidebar/LeftSidebar.vue b/src/components/LeftSidebar/LeftSidebar.vue index c526991acd9..03e07561fcd 100644 --- a/src/components/LeftSidebar/LeftSidebar.vue +++ b/src/components/LeftSidebar/LeftSidebar.vue @@ -260,14 +260,14 @@ export default { } }, 30000) - EventBus.$on('should-refresh-conversations', this.debounceFetchConversations) + EventBus.$on('should-refresh-conversations', this.handleShouldRefreshConversations) EventBus.$once('conversations-received', this.handleUnreadMention) this.mountArrowNavigation() }, beforeDestroy() { - EventBus.$off('should-refresh-conversations', this.debounceFetchConversations) + EventBus.$off('should-refresh-conversations', this.handleShouldRefreshConversations) EventBus.$off('conversations-received', this.handleUnreadMention) this.cancelSearchPossibleConversations() @@ -439,6 +439,14 @@ export default { return conversation2.lastActivity - conversation1.lastActivity }, + async handleShouldRefreshConversations(token, properties) { + if (token && properties) { + await this.$store.dispatch('setConversationProperties', { token, properties }) + } + + this.debounceFetchConversations() + }, + debounceFetchConversations: debounce(function() { if (!this.isFetchingConversations) { this.fetchConversations() diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js index fc3a1a68d03..b5dbd637f30 100644 --- a/src/store/conversationsStore.js +++ b/src/store/conversationsStore.js @@ -407,6 +407,17 @@ const actions = { commit('addConversation', conversation) }, + async setConversationProperties({ commit, getters }, { token, properties }) { + let conversation = Object.assign({}, getters.conversations[token]) + if (!conversation) { + return + } + + conversation = Object.assign(conversation, properties) + + commit('addConversation', conversation) + }, + async markConversationRead({ commit, getters }, token) { const conversation = Object.assign({}, getters.conversations[token]) if (!conversation) { diff --git a/src/utils/signaling.js b/src/utils/signaling.js index c45f5c98873..463e3f18840 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -1311,6 +1311,33 @@ Signaling.Standalone.prototype.processRoomListEvent = function(data) { // Participant list in another room changed, we don't really care } break + } else { + // Some keys do not exactly match those in the room data, so they + // are normalized before emitting the event. + const properties = data.event.update.properties + const normalizedProperties = {} + + Object.keys(properties).forEach(key => { + if (key === 'active-since') { + return + } + + let normalizedKey = key + if (key === 'lobby-state') { + normalizedKey = 'lobbyState' + } else if (key === 'lobby-timer') { + normalizedKey = 'lobbyTimer' + } else if (key === 'read-only') { + normalizedKey = 'readOnly' + } else if (key === 'sip-enabled') { + normalizedKey = 'sipEnabled' + } + + normalizedProperties[normalizedKey] = properties[key] + }) + + EventBus.$emit('should-refresh-conversations', data.event.update.roomid, normalizedProperties) + break } // eslint-disable-next-line no-fallthrough case 'disinvite': From a46fa7eb8b709512a737b5dc341c3841aa3c0ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Tue, 31 Jan 2023 04:43:12 +0100 Subject: [PATCH 9/9] Clean breakout room state after stopping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the breakout rooms are stopped the lobby is enabled again in them. However, as it was first enabled and then the breakout rooms were stopped if the participants updated the room data before switching back to the parent room the lobby was briefly shown. To prevent that the breakout rooms should be stopped and, then, the lobby should be enabled again. Signed-off-by: Daniel Calviño Sánchez --- lib/Service/BreakoutRoomService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Service/BreakoutRoomService.php b/lib/Service/BreakoutRoomService.php index 9872ecdd402..e2180124ae4 100644 --- a/lib/Service/BreakoutRoomService.php +++ b/lib/Service/BreakoutRoomService.php @@ -432,6 +432,8 @@ public function stopBreakoutRooms(Room $parent): array { throw new \InvalidArgumentException('mode'); } + $this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STOPPED); + $breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken()); foreach ($breakoutRooms as $breakoutRoom) { $this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NON_MODERATORS, null); @@ -441,8 +443,6 @@ public function stopBreakoutRooms(Room $parent): array { } } - $this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STOPPED); - return $breakoutRooms; }