feat: create a readonly contactdetails

Signed-off-by: greta <gretadoci@gmail.com>
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
This commit is contained in:
greta
2024-10-23 14:42:39 +02:00
committed by Richard Steinmetz
parent 7a6a92af3b
commit b5458b1caa
7 changed files with 434 additions and 0 deletions

View File

@ -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 {

View File

@ -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 {
}

View File

@ -0,0 +1,35 @@
<?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');
Util::addStyle(Application::APP_ID, 'contacts-oca');
}
}

27
src/oca.js Normal file
View File

@ -0,0 +1,27 @@
/**
* 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'
import { mountContactDetails } from './oca/mountContactDetails.js'
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) {
return mountContactDetails(el, contactEmailAddress)
},
}

View File

@ -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 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
}

View File

@ -0,0 +1,305 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="display-contact-details">
<div v-if="loading" class="recipient-details-loading">
<NcLoadingIcon />
</div>
<!-- nothing selected or contact not found -->
<NcEmptyContent v-else-if="!contact"
class="empty-content"
:name="t('mail', 'No data for this contact')"
:description="t('mail', 'No data for this contact 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 -->
<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>
</div>
</template>
<script>
import { isMobile, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import IconContact from 'vue-material-design-icons/AccountMultiple.vue'
import mitt from 'mitt'
import { namespaces as NS } from '@nextcloud/cdav-library'
import { loadState } from '@nextcloud/initial-state'
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)
export default {
name: 'ReadOnlyContactDetails',
components: {
ContactDetailsProperty,
NcEmptyContent,
IconContact,
NcLoadingIcon,
},
mixins: [isMobile],
props: {
contactEmailAddress: {
type: String,
required: true,
},
desc: {
type: String,
required: false,
default: '',
},
},
data() {
return {
contactDetailsSelector: '.contact-details',
excludeFromBirthdayKey: 'x-nc-exclude-from-birthday-calendar',
bus: mitt(),
showMenuPopover: false,
profileEnabled,
contact: undefined,
localContact: undefined,
loading: true,
}
},
computed: {
/**
* Read-only representation of the contact title and organization.
*
* @return {string}
*/
formattedSubtitle() {
const title = this.contact.title
const organization = this.contact.org
if (title && organization) {
return t('contacts', '{title} at {organization}', {
title,
organization,
})
} else if (title) {
return title
} else if (organization) {
return organization
}
return ''
},
addressbooks() {
return this.$store.getters.getAddressbooks
},
/**
* Contact properties copied and sorted by rfcProps.fieldOrder
*
* @return {Array}
*/
sortedProperties() {
if (!this.localContact || !this.localContact.properties) {
return []
}
return this.localContact.properties
.toSorted((a, b) => {
const nameA = a.name.split('.').pop()
const nameB = b.name.split('.').pop()
return rfcProps.fieldOrder.indexOf(nameA) - rfcProps.fieldOrder.indexOf(nameB)
})
},
/**
* Contact properties filtered and grouped by rfcProps.fieldOrder
*
* @return {object}
*/
groupedProperties() {
if (!this.sortedProperties) {
return {}
}
return this.sortedProperties.reduce((list, property) => {
if (!this.canDisplay(property)) {
return list
}
if (!list[property.name]) {
list[property.name] = []
}
list[property.name].push(property)
return list
}, {})
},
/**
* The address book is read-only (e.g. shared with me).
*
* @return {boolean}
*/
addressbookIsReadOnly() {
return this.contact.addressbook?.readOnly
},
/**
* Usable addressbook object linked to the local contact
*
* @return {string}
*/
addressbook() {
return this.contact.addressbook.id
},
/**
* Fake model to use the propertyGroups component
*
* @return {object}
*/
groupsModel() {
return {
readableName: t('mail', 'Contact groups'),
icon: 'icon-contacts-dark',
}
},
},
watch: {
contact: {
handler(contact) {
this.updateLocalContact(contact)
},
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() {
try {
const email = this.contactEmailAddress
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_CARDDAV, 'text-match'],
value: email,
},
],
},
]),
]),
)
const contacts = result.flatMap(([addressBook, vcards]) =>
vcards.map((vcard) => new Contact(vcard.data, addressBook)),
)
this.contact = contacts.find((contact) => contact.email === email)
} catch (error) {
console.error('Error fetching contact:', error)
} finally {
this.loading = false
}
},
updateGroups(value) {
this.newGroupsValue = value
},
/**
* 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,
)
validate(localContact)
this.localContact = localContact
this.newGroupsValue = [...this.localContact.groups]
},
/**
* Should display the property
*
* @param {Property} property the property to check
* @return {boolean}
*/
canDisplay(property) {
// Make sure we have some model for the property and check for ITEM.PROP custom label format
const propModel = rfcProps.properties[property.name.split('.').pop()]
const propType = propModel && propModel.force
? propModel.force
: property.getDefaultType()
return propModel && propType !== 'unknown'
},
},
}
</script>
<style lang="scss" scoped>
.empty-content {
height: 100%;
}
.contact-title {
margin-left: 100px;
margin-top: 40px;
}
:deep(.property__value) {
font-size: medium !important;
}
.recipient-details-loading {
margin-top: 64px;
}
</style>

View File

@ -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,
})