summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgreta <gretadoci@gmail.com>2023-05-23 16:19:34 +0200
committergreta <gretadoci@gmail.com>2023-07-31 13:09:20 +0200
commit30b13604e079fac7cc5fb3b95026d1704eb13b33 (patch)
tree803d3a775b373ac6aea662ec8498b52c3e768d17
parentdc8bf5ac5ab10f25eff266f75f0396c494c6cdbb (diff)
Add quick actions for contacts
Signed-off-by: greta <gretadoci@gmail.com>
-rw-r--r--lib/Controller/PageController.php5
-rw-r--r--src/components/ContactDetails.vue180
-rw-r--r--src/models/contact.js20
-rw-r--r--src/models/rfcProps.js1
-rw-r--r--src/services/isTalkEnabled.js26
5 files changed, 197 insertions, 35 deletions
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index 7cd2e2aa..6680d000 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -108,6 +108,10 @@ class PageController extends Controller {
$isCircleVersionCompatible = $this->compareVersion->isCompatible($circleVersion ? $circleVersion : '0.0.0', 22);
// Check whether group sharing is enabled or not
$isGroupSharingEnabled = $this->config->getAppValue('core', 'shareapi_allow_group_sharing', 'yes') === 'yes';
+ $talkVersion = $this->appManager->getAppVersion('spreed');
+ $isTalkEnabled = $this->appManager->isEnabledForUser('spreed') === true;
+
+ $isTalkVersionCompatible = $this->compareVersion->isCompatible($talkVersion ? $talkVersion : '0.0.0', 2);
$this->initialStateService->provideInitialState(Application::APP_ID, 'isGroupSharingEnabled', $isGroupSharingEnabled);
$this->initialStateService->provideInitialState(Application::APP_ID, 'locales', $locales);
@@ -117,6 +121,7 @@ class PageController extends Controller {
$this->initialStateService->provideInitialState(Application::APP_ID, 'enableSocialSync', $bgSyncEnabledByUser);
$this->initialStateService->provideInitialState(Application::APP_ID, 'isContactsInteractionEnabled', $isContactsInteractionEnabled);
$this->initialStateService->provideInitialState(Application::APP_ID, 'isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible);
+ $this->initialStateService->provideInitialState(Application::APP_ID, 'isTalkEnabled', $isTalkEnabled && $isTalkVersionCompatible);
Util::addScript(Application::APP_ID, 'contacts-main');
diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue
index e2efd774..fe2d271a 100644
--- a/src/components/ContactDetails.vue
+++ b/src/components/ContactDetails.vue
@@ -33,7 +33,6 @@
</EmptyContent>
<!-- TODO: add empty content while this.loadingData === true -->
-
<template v-else>
<!-- contact header -->
<DetailsHeader>
@@ -141,7 +140,6 @@
</NcButton>
</template>
</template>
-
<!-- menu actions -->
<template #actions-menu>
<ActionLink :href="contact.url"
@@ -221,32 +219,69 @@
<!-- contact details loading -->
<IconLoading v-if="loadingData" :size="20" class="contact-details" />
-
- <!-- contact details -->
- <section v-else class="contact-details">
- <!-- properties iteration -->
- <!-- using contact.key in the key and index as key to avoid conflicts between similar data and exact key -->
-
- <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"
- :bus="bus"
- :is-read-only="isReadOnly" />
+ <!-- quick actions -->
+ <div v-else-if="!loadingData" class="contact-details-wrapper">
+ <div v-if="!editMode" class="quick-actions">
+ <Actions v-if="emailAddressProperties">
+ <template #icon>
+ <IconMail :size="20" />
+ </template>
+ <ActionLink v-for="emailAddress in emailAddressList"
+ :key="emailAddress"
+ :href="'mailto:' + emailAddress">
+ <template #icon>
+ <IconMail :size="20" />
+ </template>
+ {{ emailAddress }}
+ </ActionLink>
+ </Actions>
+ <Actions v-if="phoneNumberProperties">
+ <template #icon>
+ <IconCall :size="20" />
+ </template>
+ <ActionLink v-for="phoneNumber in phoneNumberList"
+ :key="phoneNumber"
+ :href="'tel:' + phoneNumber">
+ <template #icon>
+ <IconCall :size="20" />
+ </template>
+ {{ phoneNumber }}
+ </ActionLink>
+ </Actions>
+ <NcButton v-if="isTalkEnabled && isInSystemAddressBook"
+ class="icon-talk"
+ :href="callUrl" />
+ <NcButton v-if="profilePageLink"
+ :href="profilePageLink">
+ <template #icon>
+ <IconAccount :size="20" />
+ </template>
+ </NcButton>
</div>
+ <!-- contact details -->
+ <section class="contact-details">
+ <!-- properties iteration -->
+ <!-- using contact.key in the key and index as key to avoid conflicts between similar data and exact key -->
+
+ <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"
+ :bus="bus"
+ :is-read-only="isReadOnly" />
+ </div>
+ </section>
<!-- addressbook change select - no last property because class is not applied here,
empty property because this is a required prop on regular property-select. But since
we are hijacking this... (this is supposed to be used with a ICAL.property, but to avoid code
duplication, we created a fake propModel and property with our own options here) -->
- <!-- We need to pass all addressbooksOptions not only writable ones. Otherwise the the name
- can't be displayed for readOnly addressbooks -->
<PropertySelect :prop-model="addressbookModel"
:options="addressbooksOptions"
:value.sync="addressbook"
@@ -259,30 +294,30 @@
<!-- Groups always visible -->
<PropertyGroups :prop-model="groupsModel"
- :value="localContact.groups"
+ :value.sync="localContact.groups"
:contact="contact"
:is-read-only="isReadOnly"
- class="property--groups property--last"
- @update:value="updateGroups" />
-
- <!-- new property select -->
- <AddNewProp v-if="!isReadOnly"
- :bus="bus"
- :contact="contact" />
-
- <!-- Last modified-->
- <PropertyRev v-if="contact.rev" :value="contact.rev" />
- </section>
+ class="property--groups property--last" />
+ </div>
+ <!-- new property select -->
+ <AddNewProp v-if="!isReadOnly"
+ :bus="bus"
+ :contact="contact" />
+
+ <!-- Last modified-->
+ <PropertyRev v-if="contact.rev" :value="contact.rev" />
</template>
</AppContentDetails>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
+
import { stringify } from 'ical.js'
import qr from 'qr-image'
import Vue from 'vue'
import {
+ NcActions as Actions,
NcActionButton as ActionButton,
NcActionLink as ActionLink,
NcAppContentDetails as AppContentDetails,
@@ -298,6 +333,10 @@ import IconDownload from 'vue-material-design-icons/Download.vue'
import IconDelete from 'vue-material-design-icons/Delete.vue'
import IconQr from 'vue-material-design-icons/Qrcode.vue'
import CakeIcon from 'vue-material-design-icons/Cake.vue'
+import IconMail from 'vue-material-design-icons/Email.vue'
+import IconCall from 'vue-material-design-icons/Phone.vue'
+import IconMessage from 'vue-material-design-icons/MessageProcessing.vue'
+import IconAccount from 'vue-material-design-icons/Account.vue'
import IconCopy from 'vue-material-design-icons/ContentCopy.vue'
import PencilIcon from 'vue-material-design-icons/Pencil.vue'
import CheckIcon from 'vue-material-design-icons/Check.vue'
@@ -313,11 +352,17 @@ import DetailsHeader from './DetailsHeader.vue'
import PropertyGroups from './Properties/PropertyGroups.vue'
import PropertyRev from './Properties/PropertyRev.vue'
import PropertySelect from './Properties/PropertySelect.vue'
+import { generateUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import isTalkEnabled from '../services/isTalkEnabled.js'
+
+const { profileEnabled } = loadState('user_status', 'profileEnabled', false)
export default {
name: 'ContactDetails',
components: {
+ Actions,
ActionButton,
ActionLink,
AddNewProp,
@@ -327,6 +372,10 @@ export default {
DetailsHeader,
EmptyContent,
IconContact,
+ IconMail,
+ IconMessage,
+ IconCall,
+ IconAccount,
IconDownload,
IconDelete,
IconQr,
@@ -384,6 +433,9 @@ export default {
// communication for ContactDetailsAddNewProp and ContactDetailsProperty
bus: new Vue(),
+ showMenuPopover: false,
+ profileEnabled,
+ isTalkEnabled,
}
},
@@ -592,7 +644,30 @@ export default {
return ''
},
-
+ profilePageLink() {
+ return this.contact.socialLink('NEXTCLOUD')
+ },
+ emailAddressProperties() {
+ return this.localContact.properties.find(property => property.name === 'email')
+ },
+ emailAddress() {
+ return this.emailAddressProperties?.getFirstValue()
+ },
+ phoneNumberProperties() {
+ return this.localContact.properties.find(property => property.name === 'tel')
+ },
+ phoneNumberList() {
+ return this.groupedProperties?.tel?.map(prop => prop.getFirstValue())
+ },
+ emailAddressList() {
+ return this.groupedProperties?.email?.map(prop => prop.getFirstValue())
+ },
+ callUrl() {
+ return generateUrl('/apps/spreed/?callUser={uid}', { uid: this.contact.uid })
+ },
+ isInSystemAddressBook() {
+ return this.contact.addressbook.id === 'z-server-generated--system'
+ },
},
watch: {
@@ -899,12 +974,37 @@ export default {
<style lang="scss" scoped>
// List of all properties
+.contact-details-wrapper {
+ display: inline;
+ align-items: flex-start;
+ padding: 50px 0 20px;
+ gap: 15px;
+}
+@media only screen and (max-width: 600px) {
+ .contact-details-wrapper {
+ display: block;
+ }
+}
section.contact-details {
display: flex;
flex-direction: column;
gap: 40px;
}
+.quick-actions {
+ display: flex;
+ flex: 1 0 auto;
+ gap: 15px;
+ float: right;
+ margin-right: 100px;
+ margin-top: 40px;
+}
+ @media only screen and (max-width: 600px) {
+ .quick-actions {
+ float: left;
+ margin-top: -44px;
+ }
+ }
#qrcode-modal {
::v-deep .modal-container {
display: flex;
@@ -941,4 +1041,14 @@ section.contact-details {
}
}
}
+.action-item {
+ background-color: var(--color-primary-element-light);
+ border-radius: var(--border-radius-rounded);
+}
+::v-deep .button-vue--vue-tertiary:hover,
+.button-vue--vue-tertiary:active {
+ background-color: var(--color-primary-element-light-hover) !important;
+
+ }
+
</style>
diff --git a/src/models/contact.js b/src/models/contact.js
index cc70f3f4..6e04f103 100644
--- a/src/models/contact.js
+++ b/src/models/contact.js
@@ -528,6 +528,26 @@ export default class Contact {
}
/**
+ * Return first matching link for provided type
+ * Returns empty string otherwise
+ *
+ * @param {string} type of social
+ * @readonly
+ * @memberof Contact
+ * @return {string} firstMatchingLink|''
+ */
+ socialLink(type) {
+ if (this.vCard.hasProperty('x-socialprofile')) {
+ const x = this.vCard.getAllProperties('x-socialprofile').filter(a => a.jCal[1].type.toString() === type)
+
+ if (x.length > 0) {
+ return x[0].jCal[3].toString()
+ }
+ }
+ return ''
+ }
+
+ /**
* Return the phonetic last name if exists
* Returns the displayName otherwise
*
diff --git a/src/models/rfcProps.js b/src/models/rfcProps.js
index df830a1d..b496b7f0 100644
--- a/src/models/rfcProps.js
+++ b/src/models/rfcProps.js
@@ -290,6 +290,7 @@ const properties = {
{ id: 'YOUTUBE', name: 'YouTube', placeholder: 'https://youtube.com/…' },
{ id: 'MASTODON', name: 'Mastodon', placeholder: 'https://mastodon.social/…' },
{ id: 'DIASPORA', name: 'Diaspora', placeholder: 'https://joindiaspora.com/…' },
+ { id: 'NEXTCLOUD', name: 'Nextcloud', placeholder: 'Link to profile page (https://nextcloud.example.com/…)' },
{ id: 'OTHER', name: 'Other', placeholder: 'https://example.com/…' },
],
primary: true,
diff --git a/src/services/isTalkEnabled.js b/src/services/isTalkEnabled.js
new file mode 100644
index 00000000..f49dc855
--- /dev/null
+++ b/src/services/isTalkEnabled.js
@@ -0,0 +1,26 @@
+/**
+ * @copyright Copyright (c) 2023 Greta Doci <gretadoci@gmail.com>
+ *
+ * @author Greta Doci <gretadoci@gmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { loadState } from '@nextcloud/initial-state'
+
+const isTalkEnabled = loadState('contacts', 'isTalkEnabled', false)
+export default isTalkEnabled