From 80fa85ca4ab42cec6616c2fb72f15aa82089762b Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Mon, 14 Sep 2026 10:02:38 +0200 Subject: [PATCH] perf(avatar): return actor avatar versions with the participant list Signed-off-by: Peter Ringelmann --- lib/Controller/RoomController.php | 22 +++++++++++----- lib/ResponseDefinitions.php | 2 ++ lib/Service/AvatarService.php | 26 +++++++++++++++++++ openapi-full.json | 4 +++ openapi.json | 4 +++ .../AvatarWrapper/AvatarWrapper.vue | 9 +++++++ .../Participants/ParticipantItem.vue | 1 + src/types/openapi/openapi-full.ts | 2 ++ src/types/openapi/openapi.ts | 2 ++ tests/php/Service/AvatarServiceTest.php | 21 +++++++++++++++ 10 files changed, 86 insertions(+), 7 deletions(-) diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index 1f7d9f22152..65afc96e5d0 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -66,6 +66,7 @@ use OCA\Talk\RoomPresets\Forced; use OCA\Talk\RoomPresets\Parameter; use OCA\Talk\RoomPresets\VoiceRoom; +use OCA\Talk\Service\AvatarService; use OCA\Talk\Service\BanService; use OCA\Talk\Service\BreakoutRoomService; use OCA\Talk\Service\ChecksumVerificationService; @@ -151,6 +152,7 @@ public function __construct( private readonly ITimeFactory $timeFactory, private readonly ChecksumVerificationService $checksumVerificationService, private readonly RoomFormatter $roomFormatter, + private readonly AvatarService $avatarService, private readonly Preloader $sharePreloader, private readonly IConfig $config, private readonly IAppConfig $appConfig, @@ -1321,22 +1323,25 @@ protected function formatParticipantList(array $participants, bool $includeStatu $results = $headers = $statuses = []; $maxPingAge = $this->timeFactory->getTime() - Session::SESSION_TIMEOUT_KILL; + // One participant per session, so a user on several devices repeats. + $userIds = array_values(array_unique(array_filter(array_map(static function (Participant $participant): ?string { + if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) { + return $participant->getAttendee()->getActorId(); + } + return null; + }, $participants)))); + if ($this->userId !== null && $includeStatus && count($participants) < Config::USER_STATUS_INTEGRATION_LIMIT && $this->appManager->isEnabledForUser('user_status')) { - $userIds = array_filter(array_map(static function (Participant $participant): ?string { - if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) { - return $participant->getAttendee()->getActorId(); - } - return null; - }, $participants)); - $statuses = $this->statusManager->getUserStatuses($userIds); $headers['X-Nextcloud-Has-User-Statuses'] = true; } + $avatarVersions = $this->avatarService->getUserAvatarVersions($userIds); + $currentUser = null; if ($this->userId !== null) { $currentUser = $this->userManager->get($this->userId); @@ -1409,6 +1414,9 @@ protected function formatParticipantList(array $participants, bool $includeStatu $this->participantService->leaveRoomAsSession($this->room, $participant); } + if (isset($avatarVersions[$userId])) { + $result['actorAvatarVersion'] = $avatarVersions[$userId]; + } $result['displayName'] = $participant->getAttendee()->getDisplayName(); if (!$result['displayName']) { $userDisplayName = $this->userManager->getDisplayName($userId); diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index ae5e78352ce..7143523076c 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -374,6 +374,8 @@ * roomToken: string, * // Array of session ids, each are up to 512 character long strings, or empty if no session * sessionIds: list, + * // Version of the actor's avatar, only for users. Passing it to the avatar endpoint allows a much longer cache lifetime + * actorAvatarVersion?: string, * // Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation * status?: string, * // Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation diff --git a/lib/Service/AvatarService.php b/lib/Service/AvatarService.php index 5bd6033252c..c96c537728b 100644 --- a/lib/Service/AvatarService.php +++ b/lib/Service/AvatarService.php @@ -12,6 +12,7 @@ use InvalidArgumentException; use OCA\Talk\Room; use OCA\Talk\RoomAttributes; +use OCP\Config\IUserConfig; use OCP\Files\IAppData; use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\InMemoryFile; @@ -30,6 +31,7 @@ class AvatarService { public function __construct( private readonly IAppData $appData, + private readonly IUserConfig $userConfig, private readonly IL10N $l, private readonly IURLGenerator $url, private readonly ISecureRandom $random, @@ -314,6 +316,30 @@ public function getAvatarUrl(Room $room): string { return $this->url->linkToOCSRouteAbsolute('spreed.Avatar.getAvatar', $arguments); } + /** + * Users who never set an avatar have no stored version and get "0", which is + * their real version and becomes 1 the first time they upload one. + * + * Issues one query per 50 ids. + * + * @param list $userIds + * @return array + */ + public function getUserAvatarVersions(array $userIds): array { + if ($userIds === []) { + return []; + } + + $versions = $this->userConfig->getValuesByUsers('avatar', 'version', userIds: $userIds); + + $result = []; + foreach ($userIds as $userId) { + $result[$userId] = (string)($versions[$userId] ?? 0); + } + + return $result; + } + public function getAvatarVersion(Room $room): string { $avatarVersion = $room->getAvatar(); if ($avatarVersion) { diff --git a/openapi-full.json b/openapi-full.json index e0e48a2fc24..0aed370727e 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1635,6 +1635,10 @@ "type": "string" } }, + "actorAvatarVersion": { + "type": "string", + "description": "Version of the actor's avatar, only for users. Passing it to the avatar endpoint allows a much longer cache lifetime" + }, "status": { "type": "string", "description": "Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation" diff --git a/openapi.json b/openapi.json index 27e5bda1a4b..893b0ab1b67 100644 --- a/openapi.json +++ b/openapi.json @@ -1510,6 +1510,10 @@ "type": "string" } }, + "actorAvatarVersion": { + "type": "string", + "description": "Version of the actor's avatar, only for users. Passing it to the avatar endpoint allows a much longer cache lifetime" + }, "status": { "type": "string", "description": "Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation" diff --git a/src/components/AvatarWrapper/AvatarWrapper.vue b/src/components/AvatarWrapper/AvatarWrapper.vue index c8c805d4856..fd62fded43c 100644 --- a/src/components/AvatarWrapper/AvatarWrapper.vue +++ b/src/components/AvatarWrapper/AvatarWrapper.vue @@ -37,6 +37,7 @@ :hideStatus="!showUserStatus" :verboseStatus="!showUserStatusCompact" :preloadedUserStatus="preloadedUserStatus" + :version="version" :size="size" /> diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index aa5fa45ad9c..4026ebcf55b 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -3421,6 +3421,8 @@ export type components = { roomToken: string; /** @description Array of session ids, each are up to 512 character long strings, or empty if no session */ sessionIds: string[]; + /** @description Version of the actor's avatar, only for users. Passing it to the avatar endpoint allows a much longer cache lifetime */ + actorAvatarVersion?: string; /** @description Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation */ status?: string; /** diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 18b11cecc5e..dcb58c5d8ff 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -2798,6 +2798,8 @@ export type components = { roomToken: string; /** @description Array of session ids, each are up to 512 character long strings, or empty if no session */ sessionIds: string[]; + /** @description Version of the actor's avatar, only for users. Passing it to the avatar endpoint allows a much longer cache lifetime */ + actorAvatarVersion?: string; /** @description Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation */ status?: string; /** diff --git a/tests/php/Service/AvatarServiceTest.php b/tests/php/Service/AvatarServiceTest.php index f53a4113187..c0ce366d8bc 100644 --- a/tests/php/Service/AvatarServiceTest.php +++ b/tests/php/Service/AvatarServiceTest.php @@ -12,6 +12,7 @@ use OCA\Talk\Service\AvatarService; use OCA\Talk\Service\EmojiService; use OCA\Talk\Service\RoomService; +use OCP\Config\IUserConfig; use OCP\Files\IAppData; use OCP\Files\IFilenameValidator; use OCP\IAvatarManager; @@ -27,6 +28,7 @@ #[Group('DB')] class AvatarServiceTest extends TestCase { protected IAppData&MockObject $appData; + protected IUserConfig&MockObject $userConfig; protected IL10N&MockObject $l; protected IURLGenerator&MockObject $url; protected ISecureRandom&MockObject $random; @@ -40,6 +42,7 @@ public function setUp(): void { parent::setUp(); $this->appData = $this->createMock(IAppData::class); + $this->userConfig = $this->createMock(IUserConfig::class); $this->l = $this->createMock(IL10N::class); $this->url = $this->createMock(IURLGenerator::class); $this->random = $this->createMock(ISecureRandom::class); @@ -49,6 +52,7 @@ public function setUp(): void { $this->filenameValidator = Server::get(IFilenameValidator::class); $this->service = new AvatarService( $this->appData, + $this->userConfig, $this->l, $this->url, $this->random, @@ -59,6 +63,23 @@ public function setUp(): void { ); } + public function testGetUserAvatarVersionsFillsInUsersWithoutAVersion(): void { + $this->userConfig->method('getValuesByUsers') + ->with('avatar', 'version', null, ['alice', 'bob']) + ->willReturn(['alice' => 5]); + + $this->assertSame( + ['alice' => '5', 'bob' => '0'], + $this->service->getUserAvatarVersions(['alice', 'bob']), + ); + } + + public function testGetUserAvatarVersionsSkipsAnEmptyList(): void { + $this->userConfig->expects($this->never())->method('getValuesByUsers'); + + $this->assertSame([], $this->service->getUserAvatarVersions([])); + } + public static function dataGetAvatarVersion(): array { return [ ['', 'STRING WITH 8 CHARS'],