Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions core/Controller/ContactsMenuController.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,31 @@ 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).
* Same source/order as the contacts menu with an empty filter.
*
* @return list<IEntry>
* @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 [];
}

$entries = $this->manager->getEntries($user, '');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I understand correctly that we are now calling this on every single page load? I wonder if retrieving all contacts without a filter like this will impact performance. Would you mind briefly investigating?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a quick look myself: searching with an empty filter takes the recent-statuses path, which does one address book search per recent status, up to 25, then runs every action provider over all 25 results. We keep 3. Those searches can't use an index, so they get slower as the directory grows.

Nothing waits on the response, so it's not a latency problem, but it is recurring database work on every page load.

Limiting the query to 3 instead of 25 and skipping the action providers would cover most of it. Caching per user for a few minutes on top would make repeat loads free, and slightly stale recent contacts are fine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, I think even 5 minutes caching would be fine here

if ($teamId !== null && $teamId !== '') {
$memberIds = $this->teamManager->getMembersOfTeam($teamId, $user->getUID());
$entries['contacts'] = array_filter(
$entries['contacts'],
fn (IEntry $entry) => array_key_exists($entry->getProperty('UID'), $memberIds)
);
}

return array_values(array_slice($entries['contacts'], 0, 3));
}
}
52 changes: 52 additions & 0 deletions core/src/tests/views/ContactsMenu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>()
Expand Down Expand Up @@ -124,4 +138,42 @@ 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)
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(view.container.querySelector('.contactsmenu__trigger-avatars')).toBeNull()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if this runs before loadPreviewAvatars resolves, so it could pass even with the threshold set to >= 1. Perhaps we can add a similar vi.waitFor from the test below?

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()
})
})
106 changes: 104 additions & 2 deletions core/src/views/ContactsMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand All @@ -44,6 +51,8 @@ const searchTerm = ref('')
const teams = ref<ITeam[]>([])
const selectedTeam = ref<string>('$_all_$')
const selectedTeamName = computed(() => teams.value.find((t) => t.teamId === selectedTeam.value)?.displayName)
const previewUsers = ref<IPreviewUser[]>([])
const showAvatarStack = computed(() => previewUsers.value.length >= 2)

onMounted(async () => {
const team = storage.getItem('core:contacts:team')
Expand All @@ -67,6 +76,26 @@ watch(selectedTeam, () => {
getContacts(searchTerm.value)
})

// immediate: load header avatars on mount and whenever the team filter changes
watch(selectedTeam, loadPreviewAvatars, { immediate: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kristian-zendato I didn't mean to add a second watcher here. This one has the same problem: it fires immediately, then always fires a second time we set selectedTeam above. Also since we can't predict in which order the responses arrive, this can lead to race conditions.

storage.getItem is synchronous, so perhaps we can seed the ref instead of assigning in onMounted:

const storedTeam = storage.getItem('core:contacts:team')
const selectedTeam = ref<string>(storedTeam ? JSON.parse(storedTeam) : '$_all_$')

Then drop the if (team) block. One request, correct team, all cases.


/**
* Load avatars for the People menu header trigger
*/
async function loadPreviewAvatars() {
try {
const { data } = await axios.get<IPreviewUser[]>(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 = []
}
}

/**
* Load contacts when opening the menu
*/
Expand Down Expand Up @@ -145,11 +174,32 @@ const userTeams: ITeam[] = []
<NcHeaderMenu
id="contactsmenu"
class="contactsmenu"
:class="{ 'contactsmenu--avatar-stack': showAvatarStack }"
:aria-label="t('core', 'Search contacts')"
exclude-click-outside-selectors=".v-popper__popper"
@open="onOpened">
<template #trigger>
<NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" />
<span
v-if="showAvatarStack"
class="contactsmenu__trigger-avatars"
aria-hidden="true">
<NcAvatar
v-for="(previewUser, index) in previewUsers"
:key="previewUser.isUser ? previewUser.uid : `${previewUser.fullName}-${index}`"
class="contactsmenu__trigger-avatars__avatar"
:style="{ zIndex: previewUsers.length - index }"
:user="previewUser.isUser ? previewUser.uid : undefined"
:is-no-user="!previewUser.isUser"
:display-name="previewUser.fullName"
:size="32"
disable-menu
disable-tooltip
hide-status />
</span>
<NcIconSvgWrapper
v-else
class="contactsmenu__trigger-icon"
:path="mdiContacts" />
</template>
<div class="contactsmenu__menu">
<div class="contactsmenu__menu__search-container">
Expand Down Expand Up @@ -242,12 +292,64 @@ const userTeams: ITeam[] = []

<style lang="scss" scoped>
.contactsmenu {
overflow-y: hidden;
margin-inline-end: calc(2 * var(--default-grid-baseline));

:deep(.header-menu__trigger) {
.button-vue__icon:has(.contactsmenu__trigger-avatars) {
mask: none !important;
}
}

&--avatar-stack {
width: fit-content !important;
min-width: var(--header-height);
overflow: visible;
flex-shrink: 0;

:deep(.header-menu__trigger) {
width: fit-content !important;
min-width: var(--header-height);
max-width: none;
overflow: visible !important;
padding-inline: var(--default-grid-baseline);

.button-vue__wrapper {
width: auto;
justify-content: center;
}

.button-vue__icon {
width: auto !important;
min-width: 0;
max-width: none;
height: auto;
min-height: 0;
overflow: visible;
}
}
}

&__trigger-icon {
color: var(--color-background-plain-text) !important;
}

&__trigger-avatars {
display: flex;
align-items: center;
pointer-events: none;

&__avatar {
box-sizing: content-box;
flex-shrink: 0;
border: 2px solid var(--color-background-plain);
margin-inline-start: -12px;

&:first-child {
margin-inline-start: 0;
}
}
}

&__menu {
display: flex;
flex-direction: column;
Expand Down
4 changes: 2 additions & 2 deletions dist/core-main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/core-main.js.map

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions tests/Core/Controller/ContactsMenuControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,78 @@ public function testFindOne404(): void {
$this->assertEquals([], $response->getData());
$this->assertEquals(404, $response->getStatus());
}

public function testPreviewAvatarsWithoutTeam(): void {
$user = $this->createMock(IUser::class);
$contacts = [
$this->createMock(IEntry::class),
$this->createMock(IEntry::class),
$this->createMock(IEntry::class),
$this->createMock(IEntry::class),
];

$this->userSession->expects($this->once())
->method('getUser')
->willReturn($user);
$this->contactsManager->expects($this->once())
->method('getEntries')
->with($user, '')
->willReturn([
'contacts' => $contacts,
'contactsAppEnabled' => true,
]);
$this->teamManager->expects($this->never())
->method('getMembersOfTeam');

$this->assertEquals([
$contacts[0],
$contacts[1],
$contacts[2],
], $this->controller->previewAvatars());
}

public function testPreviewAvatarsWithTeam(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('current-user');

$alice = $this->createMock(IEntry::class);
$alice->method('getProperty')->with('UID')->willReturn('alice');
$external = $this->createMock(IEntry::class);
$external->method('getProperty')->with('UID')->willReturn('contact-1');
$bob = $this->createMock(IEntry::class);
$bob->method('getProperty')->with('UID')->willReturn('bob');
$carol = $this->createMock(IEntry::class);
$carol->method('getProperty')->with('UID')->willReturn('carol');

$this->userSession->expects($this->once())
->method('getUser')
->willReturn($user);
$this->contactsManager->expects($this->once())
->method('getEntries')
->with($user, '')
->willReturn([
'contacts' => [$alice, $external, $bob, $carol],
'contactsAppEnabled' => true,
]);
$this->teamManager->expects($this->once())
->method('getMembersOfTeam')
->with('team-id', 'current-user')
->willReturn([
'alice' => 'Alice',
'bob' => 'Bob',
'carol' => 'Carol',
]);

$this->assertEquals([$alice, $bob, $carol], $this->controller->previewAvatars('team-id'));
}

public function testPreviewAvatarsWithoutUser(): void {
$this->userSession->expects($this->once())
->method('getUser')
->willReturn(null);
$this->contactsManager->expects($this->never())
->method('getEntries');

$this->assertEquals([], $this->controller->previewAvatars());
}
}
Loading