Skip to content

feat: show recent people on contact icon - #62401

Open
kristian-zendato wants to merge 1 commit into
masterfrom
feat/people-menu
Open

feat: show recent people on contact icon#62401
kristian-zendato wants to merge 1 commit into
masterfrom
feat/people-menu

Conversation

@kristian-zendato

@kristian-zendato kristian-zendato commented Jul 22, 2026

Copy link
Copy Markdown

Summary

Makes the People (contacts) header menu more inviting by showing a stack of up to 3 overlapping avatars of recent contacts on the menu trigger, instead of only the contacts icon.

  • Adds GET /contactsmenu/preview-avatars which returns the first 3 entries from the same unfiltered contacts list as the menu (Manager::getEntries with an empty filter), with optional team filtering matching the existing index endpoint.
  • Frontend loads those contacts on mount and shows an NcAvatar stack when at least 2 are available; otherwise keeps the existing contacts icon.
  • Widens the header trigger (width: fit-content) so the stack does not overlap notifications/profile and remains fully clickable.
  • Adds PHP and Vue unit tests for the new endpoint and trigger behavior.

Checklist

AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@kristian-zendato
kristian-zendato requested review from a team as code owners July 22, 2026 10:51
@kristian-zendato
kristian-zendato requested review from icewind1991, nfebe, salmart-dev, skjnldsv and sorbaugh and removed request for a team July 22, 2026 10:51
Comment thread core/src/views/ContactsMenu.vue Outdated
}
}
teams.value = [...userTeams]
await loadPreviewAvatars()

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.

Fires twice: setting selectedTeam above already triggers the new watcher. Dropping this call should be enough I think?

const view = render(ContactsMenu)
await view.findByRole('button')

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?

Comment thread core/src/views/ContactsMenu.vue Outdated
margin-inline-end: calc(2 * var(--default-grid-baseline));

:deep(.header-menu__trigger) {
opacity: 1 !important;

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.

What is this important working around? To remove the hover/focus feedback?

@kristian-zendato

Copy link
Copy Markdown
Author

@pringelmann Thank you for your comments.
I updated the code for your comments.

Signed-off-by: Kristian Zendato <kristian.zendato@nextcloud.com>
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

})

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make People menu more inviting

3 participants