diff options
-rw-r--r-- | lib/AppInfo/Application.php | 3 | ||||
-rw-r--r-- | lib/Event/LoadContactsOcaApiEvent.php | 15 | ||||
-rw-r--r-- | lib/Listener/LoadContactsOcaApi.php | 34 | ||||
-rw-r--r-- | src/oca.js | 26 | ||||
-rw-r--r-- | src/oca/mountContactDetails.js | 48 | ||||
-rw-r--r-- | src/views/ReadOnlyContactDetails.vue (renamed from src/components/ReadOnlyContactDetails.vue) | 196 | ||||
-rw-r--r-- | vite.config.js | 1 |
7 files changed, 218 insertions, 105 deletions
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 31f0f19a..aebba7b7 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -6,7 +6,9 @@ namespace OCA\Contacts\AppInfo; use OCA\Contacts\Dav\PatchPlugin; +use OCA\Contacts\Event\LoadContactsOcaApiEvent; use OCA\Contacts\Listener\LoadContactsFilesActions; +use OCA\Contacts\Listener\LoadContactsOcaApi; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -28,6 +30,7 @@ class Application extends App implements IBootstrap { public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadContactsFilesActions::class); + $context->registerEventListener(LoadContactsOcaApiEvent::class, LoadContactsOcaApi::class); } public function boot(IBootContext $context): void { diff --git a/lib/Event/LoadContactsOcaApiEvent.php b/lib/Event/LoadContactsOcaApiEvent.php new file mode 100644 index 00000000..4418c45c --- /dev/null +++ b/lib/Event/LoadContactsOcaApiEvent.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Contacts\Event; + +use OCP\EventDispatcher\Event; + +class LoadContactsOcaApiEvent extends Event { +} diff --git a/lib/Listener/LoadContactsOcaApi.php b/lib/Listener/LoadContactsOcaApi.php new file mode 100644 index 00000000..ef53b557 --- /dev/null +++ b/lib/Listener/LoadContactsOcaApi.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Contacts\Listener; + +use OCA\Contacts\AppInfo\Application; +use OCA\Contacts\Event\LoadContactsOcaApiEvent; +use OCP\AppFramework\Services\IInitialState; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +class LoadContactsOcaApi implements IEventListener { + public function __construct( + private IInitialState $initialState, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof LoadContactsOcaApiEvent)) { + return; + } + + // TODO: do we need to provide more initial state? + $this->initialState->provideInitialState('supportedNetworks', []); + Util::addScript(Application::APP_ID, 'contacts-oca'); + } +} diff --git a/src/oca.js b/src/oca.js new file mode 100644 index 00000000..6bc36018 --- /dev/null +++ b/src/oca.js @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// eslint-disable-next-line import/no-unresolved, n/no-missing-import +import 'vite/modulepreload-polyfill' + +// Global scss sheets +import './css/contacts.scss' + +// Dialogs css +import '@nextcloud/dialogs/style.css' + +window.OCA ??= {} +window.OCA.Contacts = { + /** + * @param {HTMLElement} el Html element to mount the component at + * @param {string} contactEmailAddress Email address of the contact whose details to display + * @return {Promise<object>} Mounted Vue instance (vm) + */ + async mountContactDetails(el, contactEmailAddress) { + const { mountContactDetails } = await import('./oca/mountContactDetails.js') + return mountContactDetails(el, contactEmailAddress) + }, +} diff --git a/src/oca/mountContactDetails.js b/src/oca/mountContactDetails.js new file mode 100644 index 00000000..091a9ab5 --- /dev/null +++ b/src/oca/mountContactDetails.js @@ -0,0 +1,48 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Vue from 'vue' +import ReadOnlyContactDetails from '../views/ReadOnlyContactDetails.vue' +import { createPinia, PiniaVuePlugin } from 'pinia' + +/** GLOBAL COMPONENTS AND DIRECTIVE */ +import ClickOutside from 'vue-click-outside' +import { Tooltip as VTooltip } from '@nextcloud/vue' + +import store from '../store/index.js' +import logger from '../services/logger.js' + +/** + * @param {HTMLElement} el + * @param {string} contactEmailAddress + * @return {Promise<object>} + */ +export async function mountContactDetails(el, contactEmailAddress) { + Vue.use(PiniaVuePlugin) + const pinia = createPinia() + + // Register global directives + Vue.directive('ClickOutside', ClickOutside) + Vue.directive('Tooltip', VTooltip) + + Vue.prototype.t = t + Vue.prototype.n = n + + Vue.prototype.appName = appName + Vue.prototype.appVersion = appVersion + Vue.prototype.logger = logger + Vue.prototype.OC = window.OC + Vue.prototype.OCA = window.OCA + + const Component = Vue.extend(ReadOnlyContactDetails) + const vueElement = new Component({ + pinia, + store, + propsData: { + contactEmailAddress, + }, + }).$mount(el) + return vueElement +} diff --git a/src/components/ReadOnlyContactDetails.vue b/src/views/ReadOnlyContactDetails.vue index da3cfe34..7af32bad 100644 --- a/src/components/ReadOnlyContactDetails.vue +++ b/src/views/ReadOnlyContactDetails.vue @@ -4,55 +4,54 @@ --> <template> - <div> - <!-- nothing selected or contact not found --> - <EmptyContent v-if="!contact" - class="empty-content" - :name="t('mail', 'No data for this contact')" - :description="t('mail', 'No user selected or the user has no data on their profile')"> - <template #icon> - <IconContact :size="20" /> - </template> - </EmptyContent> - - <div class="recipient-details-content"> - <div class="contact-title"> - <h6> - {{ contact.fullName }} - </h6> - <template v-if="isReadOnly"> - <!-- Subtitle here --> - <span v-html="formattedSubtitle" /> - </template> - </div> - <div v-if="!loadingData" class="contact-details-wrapper"> - <div v-for="(properties, name) in groupedProperties" - :key="name"> - <ContactDetailsProperty v-for="(property, index) in properties" - :key="`${index}-${contact.key}-${property.name}`" - :is-first-property="index===0" - :is-last-property="index === properties.length - 1" - :property="property" - :contact="contact" - :local-contact="localContact" - :contacts="contacts" - :is-read-only="isReadOnly" - :bus="bus" /> - </div> + <!-- nothing selected or contact not found --> + <NcEmptyContent v-if="!contact" + class="empty-content" + :name="t('mail', 'No data for this contact')" + :description="t('mail', 'No user selected or the user has no data on their profile')"> + <template #icon> + <IconContact :size="20" /> + </template> + </NcEmptyContent> + <div v-else + class="recipient-details-content"> + <div class="contact-title"> + <h6> + {{ contact.fullName }} + </h6> + <!-- Subtitle here --> + <span v-html="formattedSubtitle" /> + </div> + <div class="contact-details-wrapper"> + <div v-for="(properties, name) in groupedProperties" + :key="name"> + <ContactDetailsProperty v-for="(property, index) in properties" + :key="`${index}-${contact.key}-${property.name}`" + :is-first-property="index === 0" + :is-last-property="index === properties.length - 1" + :property="property" + :contact="contact" + :local-contact="localContact" + :contacts="[contact]" + :is-read-only="true" + :bus="bus" /> </div> </div> </div> </template> <script> -import { isMobile, NcEmptyContent as EmptyContent } from '@nextcloud/vue' +import { isMobile, NcEmptyContent } from '@nextcloud/vue' import IconContact from 'vue-material-design-icons/AccountMultiple.vue' import mitt from 'mitt' -import ContactDetailsProperty from './ContactDetails/ContactDetailsProperty.vue' +import { namespaces as NS } from '@nextcloud/cdav-library' import { loadState } from '@nextcloud/initial-state' -import Contact from '../store/contacts.js' +import ContactDetailsProperty from '../components/ContactDetails/ContactDetailsProperty.vue' +import Contact from '../models/contact.js' import rfcProps from '../models/rfcProps.js' import validate from '../services/validate.js' +import client from '../services/cdav.js' +import usePrincipalsStore from '../store/principals.js' const { profileEnabled } = loadState('user_status', 'profileEnabled', false) @@ -61,58 +60,41 @@ export default { components: { ContactDetailsProperty, - EmptyContent, + NcEmptyContent, IconContact, - }, mixins: [isMobile], props: { - /* contactKey: { - type: String, - default: undefined, - }, */ + contactEmailAddress: { + types: String, + required: true, + }, + /* reloadBus: { type: Object, required: true, }, + */ + // TODO: is desc used? desc: { type: String, required: false, default: '', }, - /* addressbooks: { - type: Object, - required: true, - }, */ - contact: { - type: Contact, - required: true, - }, - contacts: { - type: Array, - default: () => [], - }, - }, data() { return { - loadingData: false, - loadingUpdate: false, - // if true, the local contact have been fixed and requires a push - fixed: false, contactDetailsSelector: '.contact-details', excludeFromBirthdayKey: 'x-nc-exclude-from-birthday-calendar', - // communication for ContactDetailsAddNewProp and ContactDetailsProperty bus: mitt(), showMenuPopover: false, profileEnabled, + contact: undefined, localContact: undefined, - editMode: false, - } }, @@ -139,10 +121,10 @@ export default { return '' }, - /* // store getter addressbooks() { return this.$store.getters.getAddressbooks }, + /* // store getter contact() { return this.$store.getters.getContact(this.contactKey) }, */ @@ -156,8 +138,7 @@ export default { return [] } return this.localContact.properties - .slice(0) - .sort((a, b) => { + .toSorted((a, b) => { const nameA = a.name.split('.').pop() const nameB = b.name.split('.').pop() return rfcProps.fieldOrder.indexOf(nameA) - rfcProps.fieldOrder.indexOf(nameB) @@ -192,10 +173,6 @@ export default { addressbookIsReadOnly() { return this.contact.addressbook?.readOnly }, - isReadOnly() { - return this.addressbookIsReadOnly || !this.editMode - }, - /* /!** * Fake model to use the propertySelect component * @@ -229,19 +206,10 @@ export default { /** * Usable addressbook object linked to the local contact * - * @param {string} [addressbookId] set the addressbook id * @return {string} */ - addressbook: { - get() { - return this.contact.addressbook.id - }, - set(addressbookId) { - // Only move when the address book actually changed to prevent a conflict. - if (this.contact.addressbook.id !== addressbookId) { - this.moveContactToAddressbook(addressbookId) - } - }, + addressbook() { + return this.contact.addressbook.id }, /** @@ -264,43 +232,61 @@ export default { immediate: true, }, }, + async beforeMount() { + // Init client and stores + await client.connect({ enableCardDAV: true }) + const principalsStore = usePrincipalsStore() + principalsStore.setCurrentUserPrincipal(client) + await this.$store.dispatch('getAddressbooks') + + // Fetch contact + await this.fetchContact() + }, methods: { + async fetchContact() { + const email = this.contactEmailAddress + + console.log('abooks', this.addressbooks) + const result = await Promise.all( + this.addressbooks.map(async (addressBook) => [ + addressBook.dav, + await addressBook.dav.addressbookQuery([{ + name: [NS.IETF_CARDDAV, 'prop-filter'], + attributes: [['name', 'EMAIL']], + children: [{ + name: [NS.IETF_CALDAV, 'text-match'], + value: email, + }], + }]), + ]) + ) + const contacts = result.flatMap(([addressBook, vcards]) => + vcards.map((vcard) => new Contact(vcard.data, addressBook)), + ) + + // TODO: find strategy to merge contacts? + this.contact = contacts.find(contact => contact.email === email) + }, updateGroups(value) { this.newGroupsValue = value }, /** - * Send the local clone of contact to the store - */ - async updateContact() { - this.fixed = false - this.loadingUpdate = true - try { - await this.$store.dispatch('updateContact', this.localContact) - } finally { - this.loadingUpdate = false - } - - // if we just created the contact, we need to force update the - // localContact to match the proper store contact - if (!this.localContact.dav) { - this.logger.debug('New contact synced!', { localContact: this.localContact }) - // fetching newly created & storred contact - const contact = this.$store.getters.getContact(this.localContact.key) - await this.updateLocalContact(contact) - } - }, - /** - * Update this.localContact and set this.fixed + * Update this.localContact * * @param {Contact} contact the contact to clone */ async updateLocalContact(contact) { + if (!contact) { + this.localContact = undefined + return + } + // create empty contact and copy inner data const localContact = Object.assign( Object.create(Object.getPrototypeOf(contact)), contact, ) - this.fixed = validate(localContact) + validate(localContact) this.localContact = localContact this.newGroupsValue = [...this.localContact.groups] diff --git a/vite.config.js b/vite.config.js index 69a048f1..aa2e85bc 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,7 @@ export default createAppConfig({ 'main': path.join(__dirname, 'src', 'main.js'), 'files-action': path.join(__dirname, 'src', 'files-action.js'), 'admin-settings': path.join(__dirname, 'src', 'admin-settings.js'), + 'oca': path.join(__dirname, 'src', 'oca.js'), }, { inlineCSS: false, }) |