From 4e8c964dde36bf4425d5a089a51f0f62abce2a53 Mon Sep 17 00:00:00 2001 From: kristian-zendato Date: Tue, 11 Aug 2026 04:53:26 +0000 Subject: [PATCH] feat: show recent people on contact icon Signed-off-by: kristian-zendato --- core/Controller/ContactsMenuController.php | 46 +++++++ core/src/tests/views/ContactsMenu.spec.ts | 48 +++++++ core/src/types/contactsMenu.ts | 16 +++ core/src/views/ContactsMenu.vue | 120 +++++++++++++++-- lib/private/Contacts/ContactsMenu/Manager.php | 17 +++ .../Controller/ContactsMenuControllerTest.php | 126 ++++++++++++++++++ .../lib/Contacts/ContactsMenu/ManagerTest.php | 21 +++ 7 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 core/src/types/contactsMenu.ts diff --git a/core/Controller/ContactsMenuController.php b/core/Controller/ContactsMenuController.php index 40869e5ffd806..355849cc652cd 100644 --- a/core/Controller/ContactsMenuController.php +++ b/core/Controller/ContactsMenuController.php @@ -13,18 +13,24 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\JSONResponse; use OCP\Contacts\ContactsMenu\IEntry; +use OCP\ICacheFactory; use OCP\IRequest; use OCP\IUserSession; use OCP\Teams\ITeamManager; class ContactsMenuController extends Controller { + private const PREVIEW_AVATARS_LIMIT = 3; + private const PREVIEW_AVATARS_CACHE_TTL = 300; + public function __construct( IRequest $request, private IUserSession $userSession, private Manager $manager, private ITeamManager $teamManager, + private ICacheFactory $cacheFactory, ) { parent::__construct('core', $request); } @@ -70,4 +76,44 @@ public function findOne(int $shareType, string $shareWith) { public function getTeams(): array { return $this->teamManager->getTeamsForUser($this->userSession->getUser()->getUID()); } + + /** + * Top contacts for the People menu header avatar stack (max 3). + * Uses a lightweight query (limited results, no action providers) and + * caches per user for a few minutes. + * + * @return list + * @throws Exception + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 30, period: 300)] + #[FrontpageRoute(verb: 'GET', url: '/contactsmenu/preview-avatars')] + public function previewAvatars(?string $teamId = null): array { + $user = $this->userSession->getUser(); + if ($user === null) { + return []; + } + + $cache = $this->cacheFactory->createDistributed('contactsmenu-preview'); + $cacheKey = $user->getUID(); + $cached = $cache->get($cacheKey); + if (!is_array($cached)) { + $entries = $this->manager->getPreviewEntries($user, self::PREVIEW_AVATARS_LIMIT); + $cached = array_map( + static fn (IEntry $entry): array => $entry->jsonSerialize(), + $entries, + ); + $cache->set($cacheKey, $cached, self::PREVIEW_AVATARS_CACHE_TTL); + } + + if ($teamId !== null && $teamId !== '') { + $memberIds = $this->teamManager->getMembersOfTeam($teamId, $user->getUID()); + $cached = array_filter( + $cached, + static fn (array $entry): bool => array_key_exists($entry['uid'] ?? '', $memberIds) + ); + } + + return array_values(array_slice($cached, 0, self::PREVIEW_AVATARS_LIMIT)); + } } diff --git a/core/src/tests/views/ContactsMenu.spec.ts b/core/src/tests/views/ContactsMenu.spec.ts index a256babae6dd1..342a5af44e34d 100644 --- a/core/src/tests/views/ContactsMenu.spec.ts +++ b/core/src/tests/views/ContactsMenu.spec.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { IPreviewUser } from '../../types/contactsMenu.ts' + import { cleanup, findAllByRole, render } from '@testing-library/vue' import { afterEach, describe, expect, it, vi } from 'vitest' import ContactsMenu from '../../views/ContactsMenu.vue' @@ -19,6 +21,15 @@ vi.mock('@nextcloud/auth', () => ({ afterEach(cleanup) +function mockDefaultGets(previewUsers: IPreviewUser[] = []) { + axios.get.mockImplementation(async (url: string) => { + if (String(url).includes('/contactsmenu/preview-avatars')) { + return { data: previewUsers } + } + return { data: [] } + }) +} + describe('ContactsMenu', function() { it('shows a loading text', async () => { const { promise, resolve } = Promise.withResolvers() @@ -124,4 +135,41 @@ describe('ContactsMenu', function() { expect(items[0]!.textContent).toContain('Acosta Lancaster') expect(items[1]!.textContent).toContain('Adeline Snider') }) + + it('shows the contacts icon when fewer than two preview users are available', async () => { + mockDefaultGets([{ uid: 'alice', fullName: 'Alice', isUser: true }]) + axios.post.mockResolvedValue({ + data: { contacts: [], contactsAppEnabled: false }, + }) + + const view = render(ContactsMenu) + await view.findByRole('button') + + await vi.waitFor(() => { + expect(axios.get.mock.calls.some(([url]) => String(url).includes('/contactsmenu/preview-avatars'))).toBe(true) + expect(view.container.querySelector('.contactsmenu__trigger-avatars')).toBeNull() + expect(view.container.querySelector('.contactsmenu__trigger-icon')).toBeTruthy() + }) + }) + + it('shows an avatar stack when at least two preview users are available', async () => { + mockDefaultGets([ + { uid: 'alice', fullName: 'Alice', isUser: true }, + { uid: 'contact-1', fullName: 'External Contact', isUser: false }, + { uid: 'bob', fullName: 'Bob', isUser: true }, + ]) + axios.post.mockResolvedValue({ + data: { contacts: [], contactsAppEnabled: false }, + }) + + const view = render(ContactsMenu) + await view.findByRole('button') + + // wait for onMounted preview load + await vi.waitFor(() => { + expect(view.container.querySelector('.contactsmenu__trigger-avatars')).toBeTruthy() + }) + expect(view.container.querySelectorAll('.contactsmenu__trigger-avatars__avatar')).toHaveLength(3) + expect(view.container.querySelector('.contactsmenu__trigger-icon')).toBeNull() + }) }) diff --git a/core/src/types/contactsMenu.ts b/core/src/types/contactsMenu.ts new file mode 100644 index 0000000000000..a01b50a20af28 --- /dev/null +++ b/core/src/types/contactsMenu.ts @@ -0,0 +1,16 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface IPreviewUser { + uid: string + fullName: string + isUser: boolean +} + +export interface ITeam { + teamId: string + displayName: string + link: string +} diff --git a/core/src/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue index 8fc431f8ad648..2170bb95480fa 100644 --- a/core/src/views/ContactsMenu.vue +++ b/core/src/views/ContactsMenu.vue @@ -4,6 +4,8 @@ --> @@ -145,11 +163,32 @@ const userTeams: ITeam[] = []
@@ -242,12 +281,67 @@ const userTeams: ITeam[] = []