From 8c22afe690f6dc120b5cc6e77b3bf6f2cc86614e Mon Sep 17 00:00:00 2001 From: Nick Triantos Date: Fri, 7 Aug 2026 18:57:50 -0700 Subject: [PATCH] fix(contacts): correctly insert contacts into sorted list Use the existing favorite-and-name comparator to find the insertion point and cover beginning, middle, end, and favorite ordering. Assisted-by: Codex:gpt-5 Signed-off-by: Nick Triantos --- src/store/contacts.js | 49 ++------------ tests/javascript/store/contacts.test.js | 87 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 tests/javascript/store/contacts.test.js diff --git a/src/store/contacts.js b/src/store/contacts.js index af1a83793b..af19a5caf7 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -26,23 +26,6 @@ import validate from '../services/validate.js' ICAL.design.vcard3.param.type.multiValueSeparateDQuote = true ICAL.design.vcard.param.type.multiValueSeparateDQuote = true -function sortData(a, b) { - const nameA = typeof a.value === 'string' - ? a.value.toUpperCase() // ignore upper and lowercase - : a.value.toUnixTime() // only other sorting we support is a vCardTime - const nameB = typeof b.value === 'string' - ? b.value.toUpperCase() // ignore upper and lowercase - : b.value.toUnixTime() // only other sorting we support is a vCardTime - - const score = nameA.localeCompare - ? nameA.localeCompare(nameB) - : nameB - nameA - // if equal, fallback to the key - return score !== 0 - ? score - : a.key.localeCompare(b.key) -} - function sortByFavoriteAndName(a, b) { // favorites always on top if (a.favorite !== b.favorite) { @@ -151,31 +134,13 @@ const mutations = { favorite: contact.favorite, } - // Not using sort, splice has far better performances - // https://jsperf.com/sort-vs-splice-in-array - for (let i = 0, len = state.sortedContacts.length; i < len; i++) { - const other = state.sortedContacts[i] - - // favorite comes before non-favorite - const differentFavStatus = other.favorite !== sortedContact.favorite - const otherShouldComeFirst = differentFavStatus && other.favorite - const sameFavAndSortedFirst = !differentFavStatus && sortData(other, sortedContact) >= 0 - - if (otherShouldComeFirst || sameFavAndSortedFirst) { - continue - } - - if (i + 1 === len) { - state.sortedContacts.push(sortedContact) - } else { - state.sortedContacts.splice(i, 0, sortedContact) - } - break - } - - if (state.sortedContacts.length === 0) { - state.sortedContacts.push(sortedContact) - } + // Keep insertion linear without re-sorting the whole contacts list. + const insertionIndex = state.sortedContacts.findIndex((other) => sortByFavoriteAndName(sortedContact, other) < 0) + state.sortedContacts.splice( + insertionIndex === -1 ? state.sortedContacts.length : insertionIndex, + 0, + sortedContact, + ) state.contacts[contact.key] = contact } else { diff --git a/tests/javascript/store/contacts.test.js b/tests/javascript/store/contacts.test.js new file mode 100644 index 0000000000..17679642a2 --- /dev/null +++ b/tests/javascript/store/contacts.test.js @@ -0,0 +1,87 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Contact from '../../../src/models/contact.js' +import contactsStore from '../../../src/store/contacts.js' + +const addressbook = { id: 'default' } + +function createContact(displayName, uid, favorite = false) { + const contact = new Contact(` + BEGIN:VCARD + VERSION:4.0 + UID:${uid} + FN:${displayName} + END:VCARD`.replace(/\t/gm, ''), addressbook) + contact.dav = { favorite } + return contact +} + +describe('contacts store', () => { + let state + + beforeEach(() => { + state = { + contacts: {}, + sortedContacts: [], + orderKey: 'displayName', + } + }) + + test('inserts a contact before all existing contacts', () => { + const nextcloudy = createContact('Nextcloudy McCloudface', 'nextcloudy') + const name = createContact('Name', 'name') + contactsStore.mutations.addContact(state, nextcloudy) + + contactsStore.mutations.addContact(state, name) + + expect(state.sortedContacts.map(({ key }) => key)).toEqual([ + name.key, + nextcloudy.key, + ]) + }) + + test('inserts a contact between existing contacts', () => { + const aaron = createContact('Aaron', 'aaron') + const nextcloudy = createContact('Nextcloudy McCloudface', 'nextcloudy') + const name = createContact('Name', 'name') + contactsStore.mutations.addContact(state, aaron) + contactsStore.mutations.addContact(state, nextcloudy) + + contactsStore.mutations.addContact(state, name) + + expect(state.sortedContacts.map(({ key }) => key)).toEqual([ + aaron.key, + name.key, + nextcloudy.key, + ]) + }) + + test('inserts a contact after all existing contacts', () => { + const name = createContact('Name', 'name') + const nextcloudy = createContact('Nextcloudy McCloudface', 'nextcloudy') + contactsStore.mutations.addContact(state, name) + + contactsStore.mutations.addContact(state, nextcloudy) + + expect(state.sortedContacts.map(({ key }) => key)).toEqual([ + name.key, + nextcloudy.key, + ]) + }) + + test('inserts favorite contacts before non-favorite contacts', () => { + const aaron = createContact('Aaron', 'aaron') + const zebra = createContact('Zebra', 'zebra', true) + contactsStore.mutations.addContact(state, aaron) + + contactsStore.mutations.addContact(state, zebra) + + expect(state.sortedContacts.map(({ key }) => key)).toEqual([ + zebra.key, + aaron.key, + ]) + }) +})