diff options
author | greta <gretadoci@gmail.com> | 2023-05-23 16:19:34 +0200 |
---|---|---|
committer | greta <gretadoci@gmail.com> | 2023-07-31 13:09:20 +0200 |
commit | 30b13604e079fac7cc5fb3b95026d1704eb13b33 (patch) | |
tree | 803d3a775b373ac6aea662ec8498b52c3e768d17 | |
parent | dc8bf5ac5ab10f25eff266f75f0396c494c6cdbb (diff) |
Add quick actions for contacts
Signed-off-by: greta <gretadoci@gmail.com>
-rw-r--r-- | lib/Controller/PageController.php | 5 | ||||
-rw-r--r-- | src/components/ContactDetails.vue | 180 | ||||
-rw-r--r-- | src/models/contact.js | 20 | ||||
-rw-r--r-- | src/models/rfcProps.js | 1 | ||||
-rw-r--r-- | src/services/isTalkEnabled.js | 26 |
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 |