Skip to content

Commit 7e0645c

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

6 files changed

Lines changed: 257 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: 48 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,38 @@ 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+
expect(view.container.querySelector('.contactsmenu__trigger-avatars')).toBeNull()
152+
expect(view.container.querySelector('.contactsmenu__trigger-icon')).toBeTruthy()
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/views/ContactsMenu.vue

Lines changed: 105 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')
@@ -60,13 +69,32 @@ onMounted(async () => {
6069
}
6170
}
6271
teams.value = [...userTeams]
72+
await loadPreviewAvatars()
6373
})
6474
6575
watch(selectedTeam, () => {
6676
storage.setItem('core:contacts:team', JSON.stringify(selectedTeam.value))
6777
getContacts(searchTerm.value)
78+
loadPreviewAvatars()
6879
})
6980
81+
/**
82+
* Load avatars for the People menu header trigger
83+
*/
84+
async function loadPreviewAvatars() {
85+
try {
86+
const { data } = await axios.get<IPreviewUser[]>(generateUrl('/contactsmenu/preview-avatars'), {
87+
params: {
88+
teamId: selectedTeam.value !== '$_all_$' ? selectedTeam.value : undefined,
89+
},
90+
})
91+
previewUsers.value = data
92+
} catch (error) {
93+
logger.error('could not load preview avatars', { error })
94+
previewUsers.value = []
95+
}
96+
}
97+
7098
/**
7199
* Load contacts when opening the menu
72100
*/
@@ -145,11 +173,32 @@ const userTeams: ITeam[] = []
145173
<NcHeaderMenu
146174
id="contactsmenu"
147175
class="contactsmenu"
176+
:class="{ 'contactsmenu--avatar-stack': showAvatarStack }"
148177
:aria-label="t('core', 'Search contacts')"
149178
exclude-click-outside-selectors=".v-popper__popper"
150179
@open="onOpened">
151180
<template #trigger>
152-
<NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" />
181+
<span
182+
v-if="showAvatarStack"
183+
class="contactsmenu__trigger-avatars"
184+
aria-hidden="true">
185+
<NcAvatar
186+
v-for="(previewUser, index) in previewUsers"
187+
:key="previewUser.isUser ? previewUser.uid : `${previewUser.fullName}-${index}`"
188+
class="contactsmenu__trigger-avatars__avatar"
189+
:style="{ zIndex: previewUsers.length - index }"
190+
:user="previewUser.isUser ? previewUser.uid : undefined"
191+
:is-no-user="!previewUser.isUser"
192+
:display-name="previewUser.fullName"
193+
:size="32"
194+
disable-menu
195+
disable-tooltip
196+
hide-status />
197+
</span>
198+
<NcIconSvgWrapper
199+
v-else
200+
class="contactsmenu__trigger-icon"
201+
:path="mdiContacts" />
153202
</template>
154203
<div class="contactsmenu__menu">
155204
<div class="contactsmenu__menu__search-container">
@@ -242,12 +291,66 @@ const userTeams: ITeam[] = []
242291

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