mirror of
https://github.com/nextcloud/contacts.git
synced 2025-07-20 16:54:44 +00:00
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:
@ -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 {
|
||||
|
15
lib/Event/LoadContactsOcaApiEvent.php
Normal file
15
lib/Event/LoadContactsOcaApiEvent.php
Normal 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 {
|
||||
}
|
35
lib/Listener/LoadContactsOcaApi.php
Normal file
35
lib/Listener/LoadContactsOcaApi.php
Normal 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
27
src/oca.js
Normal 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)
|
||||
},
|
||||
}
|
48
src/oca/mountContactDetails.js
Normal file
48
src/oca/mountContactDetails.js
Normal 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
|
||||
}
|
305
src/views/ReadOnlyContactDetails.vue
Normal file
305
src/views/ReadOnlyContactDetails.vue
Normal 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>
|
@ -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,
|
||||
})
|
||||
|
Reference in New Issue
Block a user