Skip to content

Commit 9fbdb48

Browse files
fix(contactsmenu): address preview avatars review feedback
Seed the team filter from storage to avoid a double-fetch race, and load header avatars via a limited query that skips action providers and caches per user for 5 minutes. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7a3859a commit 9fbdb48

6 files changed

Lines changed: 153 additions & 50 deletions

File tree

core/Controller/ContactsMenuController.php

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,21 @@
1515
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
1616
use OCP\AppFramework\Http\JSONResponse;
1717
use OCP\Contacts\ContactsMenu\IEntry;
18+
use OCP\ICacheFactory;
1819
use OCP\IRequest;
1920
use OCP\IUserSession;
2021
use OCP\Teams\ITeamManager;
2122

2223
class ContactsMenuController extends Controller {
24+
private const PREVIEW_AVATARS_LIMIT = 3;
25+
private const PREVIEW_AVATARS_CACHE_TTL = 300;
26+
2327
public function __construct(
2428
IRequest $request,
2529
private IUserSession $userSession,
2630
private Manager $manager,
2731
private ITeamManager $teamManager,
32+
private ICacheFactory $cacheFactory,
2833
) {
2934
parent::__construct('core', $request);
3035
}
@@ -73,9 +78,10 @@ public function getTeams(): array {
7378

7479
/**
7580
* Top contacts for the People menu header avatar stack (max 3).
76-
* Same source/order as the contacts menu with an empty filter.
81+
* Uses a lightweight query (limited results, no action providers) and
82+
* caches per user for a few minutes.
7783
*
78-
* @return list<IEntry>
84+
* @return list<array>
7985
* @throws Exception
8086
*/
8187
#[NoAdminRequired]
@@ -86,15 +92,26 @@ public function previewAvatars(?string $teamId = null): array {
8692
return [];
8793
}
8894

89-
$entries = $this->manager->getEntries($user, '');
95+
$cache = $this->cacheFactory->createDistributed('contactsmenu-preview');
96+
$cacheKey = $user->getUID();
97+
$cached = $cache->get($cacheKey);
98+
if (!is_array($cached)) {
99+
$entries = $this->manager->getPreviewEntries($user, self::PREVIEW_AVATARS_LIMIT);
100+
$cached = array_map(
101+
static fn (IEntry $entry): array => $entry->jsonSerialize(),
102+
$entries,
103+
);
104+
$cache->set($cacheKey, $cached, self::PREVIEW_AVATARS_CACHE_TTL);
105+
}
106+
90107
if ($teamId !== null && $teamId !== '') {
91108
$memberIds = $this->teamManager->getMembersOfTeam($teamId, $user->getUID());
92-
$entries['contacts'] = array_filter(
93-
$entries['contacts'],
94-
fn (IEntry $entry) => array_key_exists($entry->getProperty('UID'), $memberIds)
109+
$cached = array_filter(
110+
$cached,
111+
static fn (array $entry): bool => array_key_exists($entry['uid'] ?? '', $memberIds)
95112
);
96113
}
97114

98-
return array_values(array_slice($entries['contacts'], 0, 3));
115+
return array_values(array_slice($cached, 0, self::PREVIEW_AVATARS_LIMIT));
99116
}
100117
}

core/src/tests/views/ContactsMenu.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,9 @@ describe('ContactsMenu', function() {
150150

151151
await vi.waitFor(() => {
152152
expect(axios.get.mock.calls.some(([url]) => String(url).includes('/contactsmenu/preview-avatars'))).toBe(true)
153+
expect(view.container.querySelector('.contactsmenu__trigger-icon')).toBeTruthy()
153154
})
154-
await new Promise((resolve) => setTimeout(resolve, 0))
155155
expect(view.container.querySelector('.contactsmenu__trigger-avatars')).toBeNull()
156-
expect(view.container.querySelector('.contactsmenu__trigger-icon')).toBeTruthy()
157156
})
158157

159158
it('shows an avatar stack when at least two preview users are available', async () => {

core/src/views/ContactsMenu.vue

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,13 @@ const hasError = ref(false)
4949
const searchTerm = ref('')
5050
5151
const teams = ref<ITeam[]>([])
52-
const selectedTeam = ref<string>('$_all_$')
52+
const storedTeam = storage.getItem('core:contacts:team')
53+
const selectedTeam = ref<string>(storedTeam ? JSON.parse(storedTeam) : '$_all_$')
5354
const selectedTeamName = computed(() => teams.value.find((t) => t.teamId === selectedTeam.value)?.displayName)
5455
const previewUsers = ref<IPreviewUser[]>([])
5556
const showAvatarStack = computed(() => previewUsers.value.length >= 2)
5657
5758
onMounted(async () => {
58-
const team = storage.getItem('core:contacts:team')
59-
if (team) {
60-
selectedTeam.value = JSON.parse(team)
61-
}
62-
6359
if (userTeams.length === 0) {
6460
try {
6561
const { data } = await axios.get<ITeam[]>(generateUrl('/contactsmenu/teams'))
@@ -74,11 +70,9 @@ onMounted(async () => {
7470
watch(selectedTeam, () => {
7571
storage.setItem('core:contacts:team', JSON.stringify(selectedTeam.value))
7672
getContacts(searchTerm.value)
73+
loadPreviewAvatars()
7774
})
7875
79-
// immediate: load header avatars on mount and whenever the team filter changes
80-
watch(selectedTeam, loadPreviewAvatars, { immediate: true })
81-
8276
/**
8377
* Load avatars for the People menu header trigger
8478
*/
@@ -96,6 +90,9 @@ async function loadPreviewAvatars() {
9690
}
9791
}
9892
93+
// Seeded selectedTeam above so this runs once on mount with the correct team
94+
loadPreviewAvatars()
95+
9996
/**
10097
* Load contacts when opening the menu
10198
*/

lib/private/Contacts/ContactsMenu/Manager.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ public function getEntries(IUser $user, ?string $filter): array {
4747
];
4848
}
4949

50+
/**
51+
* Lightweight recent contacts for the People menu header avatar stack.
52+
* Limits the store query and skips action providers.
53+
*
54+
* @return IEntry[]
55+
* @throws Exception
56+
*/
57+
public function getPreviewEntries(IUser $user, int $limit = 3): array {
58+
$limit = max(0, $limit);
59+
if ($limit === 0) {
60+
return [];
61+
}
62+
63+
$entries = $this->store->getContacts($user, '', $limit);
64+
return array_slice($this->sortEntries($entries), 0, $limit);
65+
}
66+
5067
/**
5168
* @throws Exception
5269
*/

tests/Core/Controller/ContactsMenuControllerTest.php

Lines changed: 84 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
use OC\Contacts\ContactsMenu\Manager;
1111
use OC\Core\Controller\ContactsMenuController;
1212
use OCP\Contacts\ContactsMenu\IEntry;
13+
use OCP\ICache;
14+
use OCP\ICacheFactory;
1315
use OCP\IRequest;
1416
use OCP\IUser;
1517
use OCP\IUserSession;
@@ -21,6 +23,8 @@ class ContactsMenuControllerTest extends TestCase {
2123
private IUserSession&MockObject $userSession;
2224
private Manager&MockObject $contactsManager;
2325
private ITeamManager&MockObject $teamManager;
26+
private ICacheFactory&MockObject $cacheFactory;
27+
private ICache&MockObject $cache;
2428

2529
private ContactsMenuController $controller;
2630

@@ -32,12 +36,19 @@ protected function setUp(): void {
3236
$this->userSession = $this->createMock(IUserSession::class);
3337
$this->contactsManager = $this->createMock(Manager::class);
3438
$this->teamManager = $this->createMock(ITeamManager::class);
39+
$this->cacheFactory = $this->createMock(ICacheFactory::class);
40+
$this->cache = $this->createMock(ICache::class);
41+
42+
$this->cacheFactory->method('createDistributed')
43+
->with('contactsmenu-preview')
44+
->willReturn($this->cache);
3545

3646
$this->controller = new ContactsMenuController(
3747
$request,
3848
$this->userSession,
3949
$this->contactsManager,
4050
$this->teamManager,
51+
$this->cacheFactory,
4152
);
4253
}
4354

@@ -128,56 +139,82 @@ public function testFindOne404(): void {
128139

129140
public function testPreviewAvatarsWithoutTeam(): void {
130141
$user = $this->createMock(IUser::class);
142+
$user->method('getUID')->willReturn('current-user');
143+
131144
$contacts = [
132-
$this->createMock(IEntry::class),
133-
$this->createMock(IEntry::class),
134-
$this->createMock(IEntry::class),
135-
$this->createMock(IEntry::class),
145+
$this->createPreviewEntry('alice', 'Alice'),
146+
$this->createPreviewEntry('bob', 'Bob'),
147+
$this->createPreviewEntry('carol', 'Carol'),
148+
$this->createPreviewEntry('dave', 'Dave'),
149+
];
150+
$expected = [
151+
$contacts[0]->jsonSerialize(),
152+
$contacts[1]->jsonSerialize(),
153+
$contacts[2]->jsonSerialize(),
136154
];
137155

138156
$this->userSession->expects($this->once())
139157
->method('getUser')
140158
->willReturn($user);
159+
$this->cache->expects($this->once())
160+
->method('get')
161+
->with('current-user')
162+
->willReturn(null);
141163
$this->contactsManager->expects($this->once())
142-
->method('getEntries')
143-
->with($user, '')
144-
->willReturn([
145-
'contacts' => $contacts,
146-
'contactsAppEnabled' => true,
147-
]);
164+
->method('getPreviewEntries')
165+
->with($user, 3)
166+
->willReturn([$contacts[0], $contacts[1], $contacts[2]]);
167+
$this->cache->expects($this->once())
168+
->method('set')
169+
->with('current-user', $expected, 300);
148170
$this->teamManager->expects($this->never())
149171
->method('getMembersOfTeam');
150172

151-
$this->assertEquals([
152-
$contacts[0],
153-
$contacts[1],
154-
$contacts[2],
155-
], $this->controller->previewAvatars());
173+
$this->assertEquals($expected, $this->controller->previewAvatars());
174+
}
175+
176+
public function testPreviewAvatarsUsesCache(): void {
177+
$user = $this->createMock(IUser::class);
178+
$user->method('getUID')->willReturn('current-user');
179+
$cached = [
180+
['uid' => 'alice', 'fullName' => 'Alice', 'isUser' => true],
181+
['uid' => 'bob', 'fullName' => 'Bob', 'isUser' => true],
182+
];
183+
184+
$this->userSession->expects($this->once())
185+
->method('getUser')
186+
->willReturn($user);
187+
$this->cache->expects($this->once())
188+
->method('get')
189+
->with('current-user')
190+
->willReturn($cached);
191+
$this->contactsManager->expects($this->never())
192+
->method('getPreviewEntries');
193+
$this->cache->expects($this->never())
194+
->method('set');
195+
196+
$this->assertEquals($cached, $this->controller->previewAvatars());
156197
}
157198

158199
public function testPreviewAvatarsWithTeam(): void {
159200
$user = $this->createMock(IUser::class);
160201
$user->method('getUID')->willReturn('current-user');
161202

162-
$alice = $this->createMock(IEntry::class);
163-
$alice->method('getProperty')->with('UID')->willReturn('alice');
164-
$external = $this->createMock(IEntry::class);
165-
$external->method('getProperty')->with('UID')->willReturn('contact-1');
166-
$bob = $this->createMock(IEntry::class);
167-
$bob->method('getProperty')->with('UID')->willReturn('bob');
168-
$carol = $this->createMock(IEntry::class);
169-
$carol->method('getProperty')->with('UID')->willReturn('carol');
203+
$cached = [
204+
['uid' => 'alice', 'fullName' => 'Alice', 'isUser' => true],
205+
['uid' => 'contact-1', 'fullName' => 'External', 'isUser' => false],
206+
['uid' => 'bob', 'fullName' => 'Bob', 'isUser' => true],
207+
];
170208

171209
$this->userSession->expects($this->once())
172210
->method('getUser')
173211
->willReturn($user);
174-
$this->contactsManager->expects($this->once())
175-
->method('getEntries')
176-
->with($user, '')
177-
->willReturn([
178-
'contacts' => [$alice, $external, $bob, $carol],
179-
'contactsAppEnabled' => true,
180-
]);
212+
$this->cache->expects($this->once())
213+
->method('get')
214+
->with('current-user')
215+
->willReturn($cached);
216+
$this->contactsManager->expects($this->never())
217+
->method('getPreviewEntries');
181218
$this->teamManager->expects($this->once())
182219
->method('getMembersOfTeam')
183220
->with('team-id', 'current-user')
@@ -187,16 +224,31 @@ public function testPreviewAvatarsWithTeam(): void {
187224
'carol' => 'Carol',
188225
]);
189226

190-
$this->assertEquals([$alice, $bob, $carol], $this->controller->previewAvatars('team-id'));
227+
$this->assertEquals([
228+
$cached[0],
229+
$cached[2],
230+
], $this->controller->previewAvatars('team-id'));
191231
}
192232

193233
public function testPreviewAvatarsWithoutUser(): void {
194234
$this->userSession->expects($this->once())
195235
->method('getUser')
196236
->willReturn(null);
197237
$this->contactsManager->expects($this->never())
198-
->method('getEntries');
238+
->method('getPreviewEntries');
239+
$this->cache->expects($this->never())
240+
->method('get');
199241

200242
$this->assertEquals([], $this->controller->previewAvatars());
201243
}
244+
245+
private function createPreviewEntry(string $uid, string $fullName): IEntry&MockObject {
246+
$entry = $this->createMock(IEntry::class);
247+
$entry->method('jsonSerialize')->willReturn([
248+
'uid' => $uid,
249+
'fullName' => $fullName,
250+
'isUser' => true,
251+
]);
252+
return $entry;
253+
}
202254
}

tests/lib/Contacts/ContactsMenu/ManagerTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,27 @@ public function testGetFilteredEntriesMinSearchStringLength(): void {
155155
$this->assertEquals($expected, $data);
156156
}
157157

158+
public function testGetPreviewEntries(): void {
159+
$user = $this->createMock(IUser::class);
160+
$entries = $this->generateTestEntries();
161+
162+
$this->contactsStore->expects($this->once())
163+
->method('getContacts')
164+
->with($user, '', 3)
165+
->willReturn($entries);
166+
$this->actionProviderStore->expects($this->never())
167+
->method('getProviders');
168+
$this->config->expects($this->never())
169+
->method('getSystemValueInt');
170+
171+
$data = $this->manager->getPreviewEntries($user, 3);
172+
173+
$this->assertCount(3, $data);
174+
$this->assertSame('Contact A', $data[0]->getFullName());
175+
$this->assertSame('Contact B', $data[1]->getFullName());
176+
$this->assertSame('Contact C', $data[2]->getFullName());
177+
}
178+
158179
public function testFindOne(): void {
159180
$shareTypeFilter = 42;
160181
$shareWithFilter = 'foobar';

0 commit comments

Comments
 (0)