diff options
author | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2021-02-26 15:37:35 +0100 |
---|---|---|
committer | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2021-05-30 10:28:56 +0200 |
commit | d6030761c32d047b2208376a99d75b72c7c57987 (patch) | |
tree | 3f8e6bddfaa2f1cb18f81d621979faff78123bd0 | |
parent | 9acea39cb94222c8d6e4a6c0f7d8dbac5055d1f4 (diff) |
Circles listing base
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
27 files changed, 2332 insertions, 908 deletions
diff --git a/babel.config.js b/babel.config.js index 5496c985..c8758255 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,7 @@ module.exports = { plugins: [ '@babel/plugin-syntax-dynamic-import', + ['@babel/plugin-proposal-class-properties', { loose: true }], ], presets: [ [ diff --git a/css/icons.scss b/css/icons.scss index 853289d5..45e5166b 100644 --- a/css/icons.scss +++ b/css/icons.scss @@ -32,6 +32,7 @@ @include icon-black-white('clone', 'contacts', 2); @include icon-black-white('sync', 'contacts', 2); @include icon-black-white('recent-actors', 'contacts', 1); +@include icon-black-white('circles', 'contacts', 1); // social network icons: @include icon-black-white('facebook', 'contacts', 2); // “facebook (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/facebook?style=brands) diff --git a/img/circles.svg b/img/circles.svg new file mode 100644 index 00000000..caf45591 --- /dev/null +++ b/img/circles.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 57 57" width="64" height="64"><path d="M7.1 28.5A21.4 21.4 0 0 1 28.5 7.1m10.7 40A21.4 21.4 0 0 1 10 39M39.2 10A21.4 21.4 0 0 1 47 39.2" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><circle cx="28.5" cy="7.1" r="6.5" fill="#fff" /><circle cx="39.2" cy="-10" r="6.5" transform="rotate(90)" fill="#fff" /><circle cx="39.2" cy="-47" r="6.5" transform="rotate(90)" fill="#fff" /></svg>
\ No newline at end of file diff --git a/package.json b/package.json index 8f9907fd..db5467c3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@nextcloud/auth": "^1.3.0", "@nextcloud/axios": "^1.6.0", "@nextcloud/dialogs": "^3.1.2", + "@nextcloud/event-bus": "^1.2.0", "@nextcloud/initial-state": "^1.2.0", "@nextcloud/l10n": "^1.4.1", "@nextcloud/moment": "^1.1.1", @@ -45,6 +46,7 @@ "@nextcloud/vue": "^3.9.0", "axios": "^0.21.1", "b64-to-blob": "^1.2.19", + "camelcase": "^5.3.1", "cdav-library": "git+https://github.com/nextcloud/cdav-library.git", "core-js": "^3.13.0", "debounce": "^1.2.1", @@ -61,6 +63,7 @@ "vue-click-outside": "^1.1.0", "vue-clipboard2": "^0.3.1", "vue-masonry": "^0.13.0", + "vue-material-design-icons": "^4.11.0", "vue-router": "^3.5.1", "vue-virtual-scroll-list": "^2.3.2", "vue-virtual-scroller": "^1.0.10", diff --git a/src/components/AppContent/CircleContent.vue b/src/components/AppContent/CircleContent.vue new file mode 100644 index 00000000..d382ae16 --- /dev/null +++ b/src/components/AppContent/CircleContent.vue @@ -0,0 +1,164 @@ +<!-- + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.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/>. + - + --> + +<template> + <AppContent> + <div v-if="!circle"> + <EmptyContent icon="icon-circles"> + {{ t('contacts', 'Please select a circle') }} + </EmptyContent> + </div> + + <div v-else id="app-content-wrapper"> + <!-- loading members --> + <AppContentDetails v-if="loading"> + <EmptyContent icon="icon-loading"> + {{ t('contacts', 'Loading circle members…') }} + </EmptyContent> + </AppContentDetails> + + <!-- not a member --> + <AppContentDetails v-else-if="!circle.isMember"> + <EmptyContent v-if="!loadingJoin" icon="icon-circles"> + {{ t('contacts', 'You are not a member of this circle') }} + + <!-- Only show the join button if the circle is accepting requests --> + <template v-if="circle.canJoin" #desc> + <button :disabled="loadingJoin" class="primary" @click="requestJoin"> + {{ t('contacts', 'Request to join') }} + </button> + </template> + </EmptyContent> + + <EmptyContent v-else-if="circle.isPendingJoin" icon="icon-loading"> + {{ t('contacts', 'Joining circle') }} + </EmptyContent> + + <EmptyContent v-else icon="icon-loading"> + {{ t('contacts', 'Joining circle') }} + </EmptyContent> + </AppContentDetails> + + <template v-else> + <!-- member list --> + <MemberList :list="members" /> + + <!-- main contacts details --> + <CircleDetails :circle-id="selectedCircle" /> + </template> + </div> + </AppContent> +</template> +<script> +import AppContentDetails from '@nextcloud/vue/dist/Components/AppContentDetails' +import AppContent from '@nextcloud/vue/dist/Components/AppContent' +import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' + +import CircleDetails from '../CircleDetails' +import MemberList from '../MemberList' +import RouterMixin from '../../mixins/RouterMixin' +import { MEMBER_LEVEL_NONE } from '../../models/constants' + +export default { + name: 'CircleContent', + + components: { + AppContent, + AppContentDetails, + CircleDetails, + EmptyContent, + MemberList, + }, + + mixins: [RouterMixin], + + props: { + loading: { + type: Boolean, + default: true, + }, + }, + + data() { + return { + loadingJoin: false, + } + }, + + computed: { + // store variables + circles() { + return this.$store.getters.getCircles + }, + circle() { + return this.$store.getters.getCircle(this.selectedCircle) + }, + members() { + return Object.values(this.circle?.members || []) + }, + + /** + * Is the current circle empty + * @returns {boolean} + */ + isEmptyCircle() { + return this.members.length === 0 + }, + + /** + * Is the current user member of this circle? + * @returns {boolean} + */ + isMemberOfCircle() { + return this.circle.initiator?.level > MEMBER_LEVEL_NONE + }, + }, + + watch: { + circle(newCircle) { + if (newCircle?.id) { + console.debug('Circles list is done loading, fetching members for', newCircle.id) + this.fetchCircleMembers(newCircle.id) + } + }, + }, + + methods: { + fetchCircleMembers(circleId) { + this.$store.dispatch('getCircleMembers', circleId) + }, + + /** + * Request to join this circle + */ + requestJoin() { + this.loadingJoin = true + }, + }, +} +</script> + +<style lang="scss" scoped> +#app-content-wrapper { + display: flex; +} +</style> diff --git a/src/components/AppContent/ContactsContent.vue b/src/components/AppContent/ContactsContent.vue new file mode 100644 index 00000000..4c84dff1 --- /dev/null +++ b/src/components/AppContent/ContactsContent.vue @@ -0,0 +1,159 @@ +<!-- + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.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/>. + - + --> + +<template> + <AppContent> + <div v-if="loading"> + <EmptyContent icon="icon-loading"> + {{ t('contacts', 'Loading contacts …') }} + </EmptyContent> + </div> + + <div v-else-if="isEmptyGroup && !isRealGroup"> + <EmptyContent icon="icon-contacts-dark"> + {{ t('contacts', 'There are no contacts yet') }} + <template #desc> + <button class="primary" @click="newContact"> + {{ t('contacts', 'Create contact') }} + </button> + </template> + </EmptyContent> + </div> + + <div v-else-if="isEmptyGroup && isRealGroup"> + <EmptyContent icon="icon-contacts-dark"> + {{ t('contacts', 'There are no contacts in this group') }} + <template #desc> + <button v-if="contacts.length === 0" class="primary" @click="addContactsToGroup(selectedGroup)"> + {{ t('contacts', 'Create contacts') }} + </button> + <button v-else class="primary" @click="addContactsToGroup(selectedGroup)"> + {{ t('contacts', 'Add contacts') }} + </button> + </template> + </EmptyContent> + </div> + + <div v-else id="app-content-wrapper"> + <!-- contacts list --> + <ContactsList + :list="contactsList" + :contacts="contacts" + :search-query="searchQuery" /> + + <!-- main contacts details --> + <ContactDetails :contact-key="selectedContact" /> + </div> + </AppContent> +</template> +<script> +import { emit } from '@nextcloud/event-bus' +import AppContent from '@nextcloud/vue/dist/Components/AppContent' +import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' + +import ContactDetails from '../ContactDetails' +import ContactsList from '../ContactsList' +import RouterMixin from '../../mixins/RouterMixin' + +export default { + name: 'ContactsContent', + + components: { + AppContent, + ContactDetails, + ContactsList, + EmptyContent, + }, + + mixins: [RouterMixin], + + props: { + loading: { + type: Boolean, + default: true, + }, + + contactsList: { + type: Array, + required: true, + }, + }, + + data() { + return { + searchQuery: '', + } + }, + + computed: { + // store variables + contacts() { + return this.$store.getters.getContacts + }, + groups() { + return this.$store.getters.getGroups + }, + sortedContacts() { + return this.$store.getters.getSortedContacts + }, + + /** + * Is this a real group ? + * Aka not a dynamically generated one like `All contacts` + * @returns {boolean} + */ + isRealGroup() { + return this.groups.findIndex(group => group.name === this.selectedGroup) > -1 + }, + /** + * Is the current group empty + * @returns {boolean} + */ + isEmptyGroup() { + return this.contactsList.length === 0 + }, + }, + + methods: { + /** + * Forward the addContactsToGroup event to the parent + * @param {string} groupName the group name + */ + addContactsToGroup(groupName) { + emit('contacts:group:append', groupName) + }, + + /** + * Forward the newContact event to the parent + */ + newContact() { + this.$emit('newContact') + }, + }, +} +</script> + +<style lang="scss" scoped> +#app-content-wrapper { + display: flex; +} +</style> diff --git a/src/components/AppNavigation/CircleNavigationItem.vue b/src/components/AppNavigation/CircleNavigationItem.vue new file mode 100644 index 00000000..8e039f43 --- /dev/null +++ b/src/components/AppNavigation/CircleNavigationItem.vue @@ -0,0 +1,183 @@ +<!-- + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.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/>. + - + --> +<template> + <AppNavigationItem + :key="circle.key" + :to="circle.router" + :title="circle.displayName" + :icon="circle.icon"> + <template v-if="loading" slot="actions"> + <ActionText icon="icon-loading-small"> + {{ t('contacts', 'Loading …') }} + </ActionText> + </template> + + <template v-else slot="actions"> + <ActionButton + v-if="circle.canManageMembers" + icon="icon-add" + @click="addMemberToCircle"> + {{ t('contacts', 'Add member') }} + </ActionButton> + + <!-- copy circle link --> + <ActionLink + :href="circle.url" + :icon="copyLoading ? 'icon-loading-small' : 'icon-public'" + @click.stop.prevent="copyToClipboard(circleUrl)"> + {{ copyButtonText }} + </ActionLink> + + <!-- leave circle --> + <ActionButton + v-if="circle.isMember" + @click="leaveCircle"> + {{ t('contacts', 'Leave circle') }} + <ExitToApp slot="icon" + :size="16" + decorative /> + </ActionButton> + + <!-- join circle --> + <ActionButton + v-else-if="circle.canJoin" + @click="joinCircle"> + {{ joinButtonTitle }} + <LocationEnter slot="icon" + :size="16" + decorative /> + </ActionButton> + + <!-- delete circle --> + <ActionButton + v-if="circle.canDelete" + icon="icon-delete" + @click="deleteCircle"> + {{ t('contacts', 'Delete') }} + </ActionButton> + </template> + + <AppNavigationCounter v-if="circle.members.length > 0" slot="counter"> + {{ circle.members.length }} + </AppNavigationCounter> + </AppNavigationItem> +</template> + +<script> +import { emit } from '@nextcloud/event-bus' + +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import ActionLink from '@nextcloud/vue/dist/Components/ActionLink' +import ActionText from '@nextcloud/vue/dist/Components/ActionText' +import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter' +import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem' +import ExitToApp from 'vue-material-design-icons/ExitToApp' +import LocationEnter from 'vue-material-design-icons/LocationEnter' + +import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin' +import { deleteCircle, joinCircle } from '../../services/circles' +import { showError } from '@nextcloud/dialogs' + +export default { + name: 'CircleNavigationItem', + + components: { + ActionButton, + ActionLink, + ActionText, + AppNavigationCounter, + AppNavigationItem, + ExitToApp, + LocationEnter, + }, + + mixins: [CopyToClipboardMixin], + + props: { + circle: { + type: Object, + required: true, + }, + }, + + data() { + return { + loading: false + } + }, + + computed: { + copyButtonText() { + if (this.copied) { + return this.copySuccess + ? t('contacts', 'Copied') + : t('contacts', 'Could not copy') + } + return t('contacts', 'Copy link') + }, + + circleUrl() { + return window.location.origin + this.circle.url + }, + + joinButtonTitle() { + if (this.circle.requireJoinAccept) { + return t('contacts', 'Request to join') + } + return t('contacts', 'Join circle') + }, + }, + + methods: { + // Trigger the entity picker view + addMemberToCircle() { + emit('contacts:circles:append', this.circle.id) + }, + + async joinCircle() { + try { + await joinCircle(this.circle.id) + } catch (error) { + showError(t('contacts', 'Unable to join the circle')) + } + + }, + + leaveCircle() { + + }, + + async deleteCircle() { + this.loading = true + + try { + await deleteCircle(this.circle.id) + this.$store.dispatch('deleteCircle', this.circle.id) + } catch (error) { + showError(t('contacts', 'Unable to delete the circle')) + } finally { + this.loading = false + } + }, + }, +} +</script> diff --git a/src/components/AppNavigation/GroupNavigationItem.vue b/src/components/AppNavigation/GroupNavigationItem.vue new file mode 100644 index 00000000..9545c7b3 --- /dev/null +++ b/src/components/AppNavigation/GroupNavigationItem.vue @@ -0,0 +1,126 @@ +<!-- + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.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/>. + - + --> +<template> + <AppNavigationItem + :key="group.key" + :to="group.router" + :title="group.name" + :icon="group.icon"> + <template slot="actions"> + <ActionButton + icon="icon-add" + @click="addContactsToGroup(group)"> + {{ t('contacts', 'Add contacts') }} + </ActionButton> + <ActionButton + icon="icon-download" + @click="downloadGroup(group)"> + {{ t('contacts', 'Download') }} + </ActionButton> + </template> + + <AppNavigationCounter v-if="group.contacts.length > 0" slot="counter"> + {{ group.contacts.length }} + </AppNavigationCounter> + </AppNavigationItem> +</template> + +<script> +import { emit } from '@nextcloud/event-bus' +import download from 'downloadjs' +import moment from 'moment' + +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter' +import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem' + +export default { + name: 'GroupNavigationItem', + + components: { + ActionButton, + AppNavigationCounter, + AppNavigationItem, + }, + + props: { + group: { + type: Object, + required: true, + }, + }, + + computed: { + }, + + methods: { + // Trigger the entity picker view + addContactsToGroup() { + emit('contacts:group:append', this.group.name) + }, + + /** + * Download group of contacts + * + * @param {Object} group of contacts to be downloaded + */ + downloadGroup(group) { + // get grouped contacts + let groupedContacts = {} + group.contacts.forEach(key => { + const id = this.contacts[key].addressbook.id + groupedContacts = Object.assign({ + [id]: { + addressbook: this.contacts[key].addressbook, + contacts: [], + }, + }, groupedContacts) + groupedContacts[id].contacts.push(this.contacts[key].url) + }) + + // create vcard promise with the requested contacts + const vcardPromise = Promise.all( + Object.keys(groupedContacts).map(key => + groupedContacts[key].addressbook.dav.addressbookMultigetExport(groupedContacts[key].contacts))) + .then(response => ({ + groupName: group.name, + data: response.map(data => data.body).join(''), + })) + + // download vcard + this.downloadVcardPromise(vcardPromise) + }, + + /** + * Download vcard promise as vcard file + * + * @param {Promise} vcardPromise the full vcf file promise + */ + async downloadVcardPromise(vcardPromise) { + vcardPromise.then(response => { + const filename = moment().format('YYYY-MM-DD_HH-mm') + '_' + response.groupName + '.vcf' + download(response.data, filename, 'text/vcard') + }) + }, + }, +} +</script> diff --git a/src/components/AppNavigation/RootNavigation.vue b/src/components/AppNavigation/RootNavigation.vue index 6d0b1b49..e80270f4 100644 --- a/src/components/AppNavigation/RootNavigation.vue +++ b/src/components/AppNavigation/RootNavigation.vue @@ -1,3 +1,25 @@ +<!-- + - @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.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/>. + - + --> + <template> <AppNavigation> <slot /> @@ -47,37 +69,11 @@ </AppNavigationCounter> </AppNavigationItem> - <AppNavigationSpacer /> - - <!-- Custom groups --> - <AppNavigationItem v-for="group in groupsMenu" - :key="group.key" - :to="group.router" - :title="group.name" - :icon="group.icon"> - <template slot="actions"> - <ActionButton - ic |