-
-
Notifications
You must be signed in to change notification settings - Fork 5.1k
feat: show recent people on contact icon #62401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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>() | ||
|
|
@@ -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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wondering if this runs before |
||
| 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() | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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) | ||
|
|
@@ -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') | ||
|
|
@@ -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 }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
const storedTeam = storage.getItem('core:contacts:team')
const selectedTeam = ref<string>(storedTeam ? JSON.parse(storedTeam) : '$_all_$')Then drop the |
||
|
|
||
| /** | ||
| * 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 | ||
| */ | ||
|
|
@@ -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"> | ||
|
|
@@ -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; | ||
|
|
||
Large diffs are not rendered by default.
Large diffs are not rendered by default.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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