Skip to content

Commit def4c5c

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

6 files changed

Lines changed: 262 additions & 5 deletions

File tree

core/Controller/ContactsMenuController.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,31 @@ public function findOne(int $shareType, string $shareWith) {
7070
public function getTeams(): array {
7171
return $this->teamManager->getTeamsForUser($this->userSession->getUser()->getUID());
7272
}
73+
74+
/**
75+
* Top contacts for the People menu header avatar stack (max 3).
76+
* Same source/order as the contacts menu with an empty filter.
77+
*
78+
* @return list<IEntry>
79+
* @throws Exception
80+
*/
81+
#[NoAdminRequired]
82+
#[FrontpageRoute(verb: 'GET', url: '/contactsmenu/preview-avatars')]
83+
public function previewAvatars(?string $teamId = null): array {
84+
$user = $this->userSession->getUser();
85+
if ($user === null) {
86+
return [];
87+
}
88+
89+
$entries = $this->manager->getEntries($user, '');
90+
if ($teamId !== null && $teamId !== '') {
91+
$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)
95+
);
96+
}
97+
98+
return array_values(array_slice($entries['contacts'], 0, 3));
99+
}
73100
}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ vi.mock('@nextcloud/auth', () => ({
1919

2020
afterEach(cleanup)
2121

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

core/src/views/ContactsMenu.vue

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import debounce from 'debounce'
1414
import { computed, nextTick, onMounted, ref, watch } from 'vue'
1515
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
1616
import NcActions from '@nextcloud/vue/components/NcActions'
17+
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
1718
import NcButton from '@nextcloud/vue/components/NcButton'
1819
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
1920
import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
@@ -23,6 +24,12 @@ import NcTextField from '@nextcloud/vue/components/NcTextField'
2324
import ContactMenuEntry from '../components/ContactsMenu/ContactMenuEntry.vue'
2425
import logger from '../logger.js'
2526
27+
interface IPreviewUser {
28+
uid: string
29+
fullName: string
30+
isUser: boolean
31+
}
32+
2633
const storage = getBuilder('core:contacts')
2734
.persist(true)
2835
.clearOnLogout(true)
@@ -44,6 +51,8 @@ const searchTerm = ref('')
4451
const teams = ref<ITeam[]>([])
4552
const selectedTeam = ref<string>('$_all_$')
4653
const selectedTeamName = computed(() => teams.value.find((t) => t.teamId === selectedTeam.value)?.displayName)
54+
const previewUsers = ref<IPreviewUser[]>([])
55+
const showAvatarStack = computed(() => previewUsers.value.length >= 2)
4756
4857
onMounted(async () => {
4958
const team = storage.getItem('core:contacts:team')
@@ -67,6 +76,26 @@ watch(selectedTeam, () => {
6776
getContacts(searchTerm.value)
6877
})
6978
79+
// immediate: load header avatars on mount and whenever the team filter changes
80+
watch(selectedTeam, loadPreviewAvatars, { immediate: true })
81+
82+
/**
83+
* Load avatars for the People menu header trigger
84+
*/
85+
async function loadPreviewAvatars() {
86+
try {
87+
const { data } = await axios.get<IPreviewUser[]>(generateUrl('/contactsmenu/preview-avatars'), {
88+
params: {
89+
teamId: selectedTeam.value !== '$_all_$' ? selectedTeam.value : undefined,
90+
},
91+
})
92+
previewUsers.value = data
93+
} catch (error) {
94+
logger.error('could not load preview avatars', { error })
95+
previewUsers.value = []
96+
}
97+
}
98+
7099
/**
71100
* Load contacts when opening the menu
72101
*/
@@ -145,11 +174,32 @@ const userTeams: ITeam[] = []
145174
<NcHeaderMenu
146175
id="contactsmenu"
147176
class="contactsmenu"
177+
:class="{ 'contactsmenu--avatar-stack': showAvatarStack }"
148178
:aria-label="t('core', 'Search contacts')"
149179
exclude-click-outside-selectors=".v-popper__popper"
150180
@open="onOpened">
151181
<template #trigger>
152-
<NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" />
182+
<span
183+
v-if="showAvatarStack"
184+
class="contactsmenu__trigger-avatars"
185+
aria-hidden="true">
186+
<NcAvatar
187+
v-for="(previewUser, index) in previewUsers"
188+
:key="previewUser.isUser ? previewUser.uid : `${previewUser.fullName}-${index}`"
189+
class="contactsmenu__trigger-avatars__avatar"
190+
:style="{ zIndex: previewUsers.length - index }"
191+
:user="previewUser.isUser ? previewUser.uid : undefined"
192+
:is-no-user="!previewUser.isUser"
193+
:display-name="previewUser.fullName"
194+
:size="32"
195+
disable-menu
196+
disable-tooltip
197+
hide-status />
198+
</span>
199+
<NcIconSvgWrapper
200+
v-else
201+
class="contactsmenu__trigger-icon"
202+
:path="mdiContacts" />
153203
</template>
154204
<div class="contactsmenu__menu">
155205
<div class="contactsmenu__menu__search-container">
@@ -242,12 +292,64 @@ const userTeams: ITeam[] = []
242292

243293
<style lang="scss" scoped>
244294
.contactsmenu {
245-
overflow-y: hidden;
295+
margin-inline-end: calc(2 * var(--default-grid-baseline));
296+
297+
:deep(.header-menu__trigger) {
298+
.button-vue__icon:has(.contactsmenu__trigger-avatars) {
299+
mask: none !important;
300+
}
301+
}
302+
303+
&--avatar-stack {
304+
width: fit-content !important;
305+
min-width: var(--header-height);
306+
overflow: visible;
307+
flex-shrink: 0;
308+
309+
:deep(.header-menu__trigger) {
310+
width: fit-content !important;
311+
min-width: var(--header-height);
312+
max-width: none;
313+
overflow: visible !important;
314+
padding-inline: var(--default-grid-baseline);
315+
316+
.button-vue__wrapper {
317+
width: auto;
318+
justify-content: center;
319+
}
320+
321+
.button-vue__icon {
322+
width: auto !important;
323+
min-width: 0;
324+
max-width: none;
325+
height: auto;
326+
min-height: 0;
327+
overflow: visible;
328+
}
329+
}
330+
}
246331
247332
&__trigger-icon {
248333
color: var(--color-background-plain-text) !important;
249334
}
250335
336+
&__trigger-avatars {
337+
display: flex;
338+
align-items: center;
339+
pointer-events: none;
340+
341+
&__avatar {
342+
box-sizing: content-box;
343+
flex-shrink: 0;
344+
border: 2px solid var(--color-background-plain);
345+
margin-inline-start: -12px;
346+
347+
&:first-child {
348+
margin-inline-start: 0;
349+
}
350+
}
351+
}
352+
251353
&__menu {
252354
display: flex;
253355
flex-direction: column;

dist/core-main.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core-main.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/Core/Controller/ContactsMenuControllerTest.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,78 @@ public function testFindOne404(): void {
125125
$this->assertEquals([], $response->getData());
126126
$this->assertEquals(404, $response->getStatus());
127127
}
128+
129+
public function testPreviewAvatarsWithoutTeam(): void {
130+
$user = $this->createMock(IUser::class);
131+
$contacts = [
132+
$this->createMock(IEntry::class),
133+
$this->createMock(IEntry::class),
134+
$this->createMock(IEntry::class),
135+
$this->createMock(IEntry::class),
136+
];
137+
138+
$this->userSession->expects($this->once())
139+
->method('getUser')
140+
->willReturn($user);
141+
$this->contactsManager->expects($this->once())
142+
->method('getEntries')
143+
->with($user, '')
144+
->willReturn([
145+
'contacts' => $contacts,
146+
'contactsAppEnabled' => true,
147+
]);
148+
$this->teamManager->expects($this->never())
149+
->method('getMembersOfTeam');
150+
151+
$this->assertEquals([
152+
$contacts[0],
153+
$contacts[1],
154+
$contacts[2],
155+
], $this->controller->previewAvatars());
156+
}
157+
158+
public function testPreviewAvatarsWithTeam(): void {
159+
$user = $this->createMock(IUser::class);
160+
$user->method('getUID')->willReturn('current-user');
161+
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');
170+
171+
$this->userSession->expects($this->once())
172+
->method('getUser')
173+
->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+
]);
181+
$this->teamManager->expects($this->once())
182+
->method('getMembersOfTeam')
183+
->with('team-id', 'current-user')
184+
->willReturn([
185+
'alice' => 'Alice',
186+
'bob' => 'Bob',
187+
'carol' => 'Carol',
188+
]);
189+
190+
$this->assertEquals([$alice, $bob, $carol], $this->controller->previewAvatars('team-id'));
191+
}
192+
193+
public function testPreviewAvatarsWithoutUser(): void {
194+
$this->userSession->expects($this->once())
195+
->method('getUser')
196+
->willReturn(null);
197+
$this->contactsManager->expects($this->never())
198+
->method('getEntries');
199+
200+
$this->assertEquals([], $this->controller->previewAvatars());
201+
}
128202
}

0 commit comments

Comments
 (0)