Skip to content

Commit b626252

Browse files
feat: show recent people on contact icon
Signed-off-by: kristian-zendato <kristian.zendato@nextcloud.com>
1 parent c849c3b commit b626252

7 files changed

Lines changed: 381 additions & 13 deletions

File tree

core/Controller/ContactsMenuController.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,24 @@
1313
use OCP\AppFramework\Http;
1414
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
1515
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
16+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
1617
use OCP\AppFramework\Http\JSONResponse;
1718
use OCP\Contacts\ContactsMenu\IEntry;
19+
use OCP\ICacheFactory;
1820
use OCP\IRequest;
1921
use OCP\IUserSession;
2022
use OCP\Teams\ITeamManager;
2123

2224
class ContactsMenuController extends Controller {
25+
private const int PREVIEW_AVATARS_LIMIT = 3;
26+
private const int PREVIEW_AVATARS_CACHE_TTL = 300;
27+
2328
public function __construct(
2429
IRequest $request,
2530
private IUserSession $userSession,
2631
private Manager $manager,
2732
private ITeamManager $teamManager,
33+
private ICacheFactory $cacheFactory,
2834
) {
2935
parent::__construct('core', $request);
3036
}
@@ -70,4 +76,44 @@ public function findOne(int $shareType, string $shareWith) {
7076
public function getTeams(): array {
7177
return $this->teamManager->getTeamsForUser($this->userSession->getUser()->getUID());
7278
}
79+
80+
/**
81+
* Top contacts for the People menu header avatar stack (max 3).
82+
* Uses a lightweight query (limited results, no action providers) and
83+
* caches per user for a few minutes.
84+
*
85+
* @return list<array>
86+
* @throws Exception
87+
*/
88+
#[NoAdminRequired]
89+
#[UserRateLimit(limit: 30, period: 300)]
90+
#[FrontpageRoute(verb: 'GET', url: '/contactsmenu/preview-avatars')]
91+
public function previewAvatars(?string $teamId = null): array {
92+
$user = $this->userSession->getUser();
93+
if ($user === null) {
94+
return [];
95+
}
96+
97+
$cache = $this->cacheFactory->createDistributed('contactsmenu-preview');
98+
$cacheKey = $user->getUID();
99+
$cached = $cache->get($cacheKey);
100+
if (!is_array($cached)) {
101+
$entries = $this->manager->getPreviewEntries($user, self::PREVIEW_AVATARS_LIMIT);
102+
$cached = array_map(
103+
static fn (IEntry $entry): array => $entry->jsonSerialize(),
104+
$entries,
105+
);
106+
$cache->set($cacheKey, $cached, self::PREVIEW_AVATARS_CACHE_TTL);
107+
}
108+
109+
if ($teamId !== null && $teamId !== '') {
110+
$memberIds = $this->teamManager->getMembersOfTeam($teamId, $user->getUID());
111+
$cached = array_filter(
112+
$cached,
113+
static fn (array $entry): bool => array_key_exists($entry['uid'] ?? '', $memberIds)
114+
);
115+
}
116+
117+
return array_values(array_slice($cached, 0, self::PREVIEW_AVATARS_LIMIT));
118+
}
73119
}

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6+
import type { IPreviewUser } from '../../types/contactsMenu.ts'
7+
68
import { cleanup, findAllByRole, render } from '@testing-library/vue'
79
import { afterEach, describe, expect, it, vi } from 'vitest'
810
import ContactsMenu from '../../views/ContactsMenu.vue'
@@ -19,6 +21,15 @@ vi.mock('@nextcloud/auth', () => ({
1921

2022
afterEach(cleanup)
2123

24+
function mockDefaultGets(previewUsers: IPreviewUser[] = []) {
25+
axios.get.mockImplementation(async (url: string) => {
26+
if (String(url).includes('/contactsmenu/preview-avatars')) {
27+
return { data: previewUsers }
28+
}
29+
return { data: [] }
30+
})
31+
}
32+
2233
describe('ContactsMenu', function() {
2334
it('shows a loading text', async () => {
2435
const { promise, resolve } = Promise.withResolvers<void>()
@@ -124,4 +135,41 @@ describe('ContactsMenu', function() {
124135
expect(items[0]!.textContent).toContain('Acosta Lancaster')
125136
expect(items[1]!.textContent).toContain('Adeline Snider')
126137
})
138+
139+
it('shows the contacts icon when fewer than two preview users are available', async () => {
140+
mockDefaultGets([{ uid: 'alice', fullName: 'Alice', isUser: true }])
141+
axios.post.mockResolvedValue({
142+
data: { contacts: [], contactsAppEnabled: false },
143+
})
144+
145+
const view = render(ContactsMenu)
146+
await view.findByRole('button')
147+
148+
await vi.waitFor(() => {
149+
expect(axios.get.mock.calls.some(([url]) => String(url).includes('/contactsmenu/preview-avatars'))).toBe(true)
150+
expect(view.container.querySelector('.contactsmenu__trigger-avatars')).toBeNull()
151+
expect(view.container.querySelector('.contactsmenu__trigger-icon')).toBeTruthy()
152+
})
153+
})
154+
155+
it('shows an avatar stack when at least two preview users are available', async () => {
156+
mockDefaultGets([
157+
{ uid: 'alice', fullName: 'Alice', isUser: true },
158+
{ uid: 'contact-1', fullName: 'External Contact', isUser: false },
159+
{ uid: 'bob', fullName: 'Bob', isUser: true },
160+
])
161+
axios.post.mockResolvedValue({
162+
data: { contacts: [], contactsAppEnabled: false },
163+
})
164+
165+
const view = render(ContactsMenu)
166+
await view.findByRole('button')
167+
168+
// wait for onMounted preview load
169+
await vi.waitFor(() => {
170+
expect(view.container.querySelector('.contactsmenu__trigger-avatars')).toBeTruthy()
171+
})
172+
expect(view.container.querySelectorAll('.contactsmenu__trigger-avatars__avatar')).toHaveLength(3)
173+
expect(view.container.querySelector('.contactsmenu__trigger-icon')).toBeNull()
174+
})
127175
})

core/src/types/contactsMenu.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
export interface IPreviewUser {
7+
uid: string
8+
fullName: string
9+
isUser: boolean
10+
}
11+
12+
export interface ITeam {
13+
teamId: string
14+
displayName: string
15+
link: string
16+
}

core/src/views/ContactsMenu.vue

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
-->
55

66
<script setup lang="ts">
7+
import type { IPreviewUser, ITeam } from '../types/contactsMenu.ts'
8+
79
import { mdiAccountGroupOutline, mdiContacts, mdiMagnify } from '@mdi/js'
810
import { getCurrentUser } from '@nextcloud/auth'
911
import axios from '@nextcloud/axios'
@@ -14,6 +16,7 @@ import debounce from 'debounce'
1416
import { computed, nextTick, onMounted, ref, watch } from 'vue'
1517
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
1618
import NcActions from '@nextcloud/vue/components/NcActions'
19+
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
1720
import NcButton from '@nextcloud/vue/components/NcButton'
1821
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
1922
import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
@@ -42,15 +45,13 @@ const hasError = ref(false)
4245
const searchTerm = ref('')
4346
4447
const teams = ref<ITeam[]>([])
45-
const selectedTeam = ref<string>('$_all_$')
48+
const storedTeam = storage.getItem('core:contacts:team')
49+
const selectedTeam = ref<string>(storedTeam ? JSON.parse(storedTeam) : '$_all_$')
4650
const selectedTeamName = computed(() => teams.value.find((t) => t.teamId === selectedTeam.value)?.displayName)
51+
const previewUsers = ref<IPreviewUser[]>([])
52+
const showAvatarStack = computed(() => previewUsers.value.length >= 2)
4753
4854
onMounted(async () => {
49-
const team = storage.getItem('core:contacts:team')
50-
if (team) {
51-
selectedTeam.value = JSON.parse(team)
52-
}
53-
5455
if (userTeams.length === 0) {
5556
try {
5657
const { data } = await axios.get<ITeam[]>(generateUrl('/contactsmenu/teams'))
@@ -65,8 +66,29 @@ onMounted(async () => {
6566
watch(selectedTeam, () => {
6667
storage.setItem('core:contacts:team', JSON.stringify(selectedTeam.value))
6768
getContacts(searchTerm.value)
69+
loadPreviewAvatars()
6870
})
6971
72+
/**
73+
* Load avatars for the People menu header trigger
74+
*/
75+
async function loadPreviewAvatars() {
76+
try {
77+
const { data } = await axios.get<IPreviewUser[]>(generateUrl('/contactsmenu/preview-avatars'), {
78+
params: {
79+
teamId: selectedTeam.value !== '$_all_$' ? selectedTeam.value : undefined,
80+
},
81+
})
82+
previewUsers.value = data
83+
} catch (error) {
84+
logger.error('could not load preview avatars', { error })
85+
previewUsers.value = []
86+
}
87+
}
88+
89+
// Seeded selectedTeam above so this runs once on mount with the correct team
90+
loadPreviewAvatars()
91+
7092
/**
7193
* Load contacts when opening the menu
7294
*/
@@ -132,11 +154,7 @@ function focusInput() {
132154
</script>
133155

134156
<script lang="ts">
135-
interface ITeam {
136-
teamId: string
137-
displayName: string
138-
link: string
139-
}
157+
import type { ITeam } from '../types/contactsMenu.ts'
140158
141159
const userTeams: ITeam[] = []
142160
</script>
@@ -145,11 +163,32 @@ const userTeams: ITeam[] = []
145163
<NcHeaderMenu
146164
id="contactsmenu"
147165
class="contactsmenu"
166+
:class="{ 'contactsmenu--avatar-stack': showAvatarStack }"
148167
:aria-label="t('core', 'Search contacts')"
149168
exclude-click-outside-selectors=".v-popper__popper"
150169
@open="onOpened">
151170
<template #trigger>
152-
<NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" />
171+
<span
172+
v-if="showAvatarStack"
173+
class="contactsmenu__trigger-avatars"
174+
aria-hidden="true">
175+
<NcAvatar
176+
v-for="(previewUser, index) in previewUsers"
177+
:key="previewUser.isUser ? previewUser.uid : `${previewUser.fullName}-${index}`"
178+
class="contactsmenu__trigger-avatars__avatar"
179+
:style="{ zIndex: previewUsers.length - index }"
180+
:user="previewUser.isUser ? previewUser.uid : undefined"
181+
:is-no-user="!previewUser.isUser"
182+
:display-name="previewUser.fullName"
183+
:size="32"
184+
disable-menu
185+
disable-tooltip
186+
hide-status />
187+
</span>
188+
<NcIconSvgWrapper
189+
v-else
190+
class="contactsmenu__trigger-icon"
191+
:path="mdiContacts" />
153192
</template>
154193
<div class="contactsmenu__menu">
155194
<div class="contactsmenu__menu__search-container">
@@ -242,12 +281,67 @@ const userTeams: ITeam[] = []
242281

243282
<style lang="scss" scoped>
244283
.contactsmenu {
245-
overflow-y: hidden;
284+
margin-inline-end: calc(2 * var(--default-grid-baseline));
285+
286+
:deep(.header-menu__trigger) {
287+
// NcHeaderMenu applies --header-menu-icon-mask (vertical alpha fade) to
288+
// .button-vue__icon:not(:has(svg)). Avatars need the full face visible.
289+
.button-vue__icon:has(.contactsmenu__trigger-avatars) {
290+
mask: none !important;
291+
}
292+
}
293+
294+
&--avatar-stack {
295+
width: fit-content !important;
296+
min-width: var(--header-height);
297+
overflow: visible;
298+
flex-shrink: 0;
299+
300+
:deep(.header-menu__trigger) {
301+
width: fit-content !important;
302+
min-width: var(--header-height);
303+
max-width: none;
304+
overflow: visible !important;
305+
padding-inline: var(--default-grid-baseline);
306+
307+
.button-vue__wrapper {
308+
width: auto;
309+
justify-content: center;
310+
}
311+
312+
.button-vue__icon {
313+
width: auto !important;
314+
min-width: 0;
315+
max-width: none;
316+
height: auto;
317+
min-height: 0;
318+
overflow: visible;
319+
}
320+
}
321+
}
246322
247323
&__trigger-icon {
248324
color: var(--color-background-plain-text) !important;
249325
}
250326
327+
&__trigger-avatars {
328+
display: flex;
329+
align-items: center;
330+
pointer-events: none;
331+
332+
&__avatar {
333+
box-sizing: content-box;
334+
flex-shrink: 0;
335+
--contactsmenu-avatar-outline: var(--border-width-input) solid color-mix(in srgb, var(--color-background-plain-text), transparent 75%);
336+
outline: var(--contactsmenu-avatar-outline);
337+
margin-inline-start: -12px;
338+
339+
&:first-child {
340+
margin-inline-start: 0;
341+
}
342+
}
343+
}
344+
251345
&__menu {
252346
display: flex;
253347
flex-direction: column;

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
*/

0 commit comments

Comments
 (0)