diff --git a/core/Controller/ContactsMenuController.php b/core/Controller/ContactsMenuController.php index 40869e5ffd806..b0b6520716405 100644 --- a/core/Controller/ContactsMenuController.php +++ b/core/Controller/ContactsMenuController.php @@ -15,16 +15,21 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; 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 +75,43 @@ 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] + #[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..928ead44cc6c9 100644 --- a/core/src/tests/views/ContactsMenu.spec.ts +++ b/core/src/tests/views/ContactsMenu.spec.ts @@ -19,6 +19,20 @@ vi.mock('@nextcloud/auth', () => ({ afterEach(cleanup) +function mockDefaultGets(previewUsers: Array<{ uid: string, fullName: string, isUser?: boolean }> = []) { + axios.get.mockImplementation(async (url: string) => { + if (String(url).includes('/contactsmenu/preview-avatars')) { + return { + data: previewUsers.map((user) => ({ + isUser: true, + ...user, + })), + } + } + return { data: [] } + }) +} + describe('ContactsMenu', function() { it('shows a loading text', async () => { const { promise, resolve } = Promise.withResolvers() @@ -124,4 +138,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/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue index 8fc431f8ad648..21a82f5307bbb 100644 --- a/core/src/views/ContactsMenu.vue +++ b/core/src/views/ContactsMenu.vue @@ -14,6 +14,7 @@ import debounce from 'debounce' import { computed, nextTick, onMounted, ref, watch } from 'vue' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcActions from '@nextcloud/vue/components/NcActions' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' import NcButton from '@nextcloud/vue/components/NcButton' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' @@ -23,6 +24,12 @@ import NcTextField from '@nextcloud/vue/components/NcTextField' import ContactMenuEntry from '../components/ContactsMenu/ContactMenuEntry.vue' import logger from '../logger.js' +interface IPreviewUser { + uid: string + fullName: string + isUser: boolean +} + const storage = getBuilder('core:contacts') .persist(true) .clearOnLogout(true) @@ -42,15 +49,13 @@ const hasError = ref(false) const searchTerm = ref('') const teams = ref([]) -const selectedTeam = ref('$_all_$') +const storedTeam = storage.getItem('core:contacts:team') +const selectedTeam = ref(storedTeam ? JSON.parse(storedTeam) : '$_all_$') const selectedTeamName = computed(() => teams.value.find((t) => t.teamId === selectedTeam.value)?.displayName) +const previewUsers = ref([]) +const showAvatarStack = computed(() => previewUsers.value.length >= 2) onMounted(async () => { - const team = storage.getItem('core:contacts:team') - if (team) { - selectedTeam.value = JSON.parse(team) - } - if (userTeams.length === 0) { try { const { data } = await axios.get(generateUrl('/contactsmenu/teams')) @@ -65,8 +70,29 @@ onMounted(async () => { watch(selectedTeam, () => { storage.setItem('core:contacts:team', JSON.stringify(selectedTeam.value)) getContacts(searchTerm.value) + loadPreviewAvatars() }) +/** + * Load avatars for the People menu header trigger + */ +async function loadPreviewAvatars() { + try { + const { data } = await axios.get(generateUrl('/contactsmenu/preview-avatars'), { + params: { + teamId: selectedTeam.value !== '$_all_$' ? selectedTeam.value : undefined, + }, + }) + previewUsers.value = data + } catch (error) { + logger.error('could not load preview avatars', { error }) + previewUsers.value = [] + } +} + +// Seeded selectedTeam above so this runs once on mount with the correct team +loadPreviewAvatars() + /** * Load contacts when opening the menu */ @@ -145,11 +171,32 @@ const userTeams: ITeam[] = []
@@ -242,12 +289,67 @@ const userTeams: ITeam[] = []