diff options
author | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2021-04-27 19:38:41 +0200 |
---|---|---|
committer | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2021-05-30 10:28:57 +0200 |
commit | 9facd8fbf3621df573f0e56e54da0612482c3c62 (patch) | |
tree | f0130a5354a36f312444e017731bb2951f10d17a /src | |
parent | bb5f38e9231b659f348fbd83422af0d65194037b (diff) |
Refactor circle actions
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Diffstat (limited to 'src')
-rw-r--r-- | src/components/AppContent/CircleContent.vue | 48 | ||||
-rw-r--r-- | src/components/AppNavigation/CircleNavigationItem.vue | 93 | ||||
-rw-r--r-- | src/components/AppNavigation/RootNavigation.vue | 10 | ||||
-rw-r--r-- | src/components/CircleDetails.vue | 134 | ||||
-rw-r--r-- | src/components/CircleDetails/CircleConfigs.vue | 35 | ||||
-rw-r--r-- | src/components/CircleDetails/ContentHeading.vue | 13 | ||||
-rw-r--r-- | src/components/ContactDetails.vue | 1 | ||||
-rw-r--r-- | src/components/DetailsHeader.vue | 10 | ||||
-rw-r--r-- | src/components/MembersList/MembersListItem.vue | 13 | ||||
-rw-r--r-- | src/mixins/CircleActionsMixin.js | 152 | ||||
-rw-r--r-- | src/models/constants.ts | 6 | ||||
-rw-r--r-- | src/services/circles.d.ts | 14 | ||||
-rw-r--r-- | src/services/circles.ts | 18 | ||||
-rw-r--r-- | src/store/circles.js | 24 |
14 files changed, 405 insertions, 166 deletions
diff --git a/src/components/AppContent/CircleContent.vue b/src/components/AppContent/CircleContent.vue index 164aaf5b..eda997df 100644 --- a/src/components/AppContent/CircleContent.vue +++ b/src/components/AppContent/CircleContent.vue @@ -36,34 +36,36 @@ </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', 'Your request to join this circle is pending approval') }} - </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" /> + <CircleDetails :circle="circle"> + <!-- not a member --> + <template v-if="!circle.isMember"> + <!-- Join request in progress --> + <EmptyContent v-if="loadingJoin" icon="icon-loading"> + {{ t('contacts', 'Joining circle') }} + </EmptyContent> + + <!-- Pending request validation --> + <EmptyContent v-else-if="circle.isPendingJoin" icon="icon-loading"> + {{ t('contacts', 'Your request to join this circle is pending approval') }} + </EmptyContent> + + <EmptyContent v-else 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> + </template> + </CircleDetails> </template> </div> </AppContent> diff --git a/src/components/AppNavigation/CircleNavigationItem.vue b/src/components/AppNavigation/CircleNavigationItem.vue index eadea9a7..d6787a6e 100644 --- a/src/components/AppNavigation/CircleNavigationItem.vue +++ b/src/components/AppNavigation/CircleNavigationItem.vue @@ -1,5 +1,5 @@ <!-- - - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com> - - @author John Molakvoæ <skjnldsv@protonmail.com> - @@ -25,7 +25,7 @@ :to="circle.router" :title="circle.displayName" :icon="circle.icon"> - <template v-if="loading" slot="actions"> + <template v-if="loadingAction" slot="actions"> <ActionText icon="icon-loading-small"> {{ t('contacts', 'Loading …') }} </ActionText> @@ -50,7 +50,7 @@ <!-- leave circle --> <ActionButton v-if="circle.canLeave" - @click="leaveCircle"> + @click="confirmLeaveCircle"> {{ t('contacts', 'Leave circle') }} <ExitToApp slot="icon" :size="16" @@ -71,7 +71,7 @@ <ActionButton v-if="circle.canDelete" icon="icon-delete" - @click="deleteCircle"> + @click="confirmDeleteCircle"> {{ t('contacts', 'Delete') }} </ActionButton> </template> @@ -83,8 +83,6 @@ </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' @@ -93,10 +91,8 @@ 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 { joinCircle } from '../../services/circles.ts' -import { showError } from '@nextcloud/dialogs' import Circle from '../../models/circle.ts' -import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin' +import CircleActionsMixin from '../../mixins/CircleActionsMixin' export default { name: 'CircleNavigationItem', @@ -111,7 +107,7 @@ export default { LocationEnter, }, - mixins: [CopyToClipboardMixin], + mixins: [CircleActionsMixin], props: { circle: { @@ -120,87 +116,10 @@ export default { }, }, - 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() { - const route = this.$router.resolve(this.circle.router) - return window.location.origin + route.href - }, - - joinButtonTitle() { - if (this.circle.requireJoinAccept) { - return t('contacts', 'Request to join') - } - return t('contacts', 'Join circle') - }, - memberCount() { return Object.values(this.circle?.members || []).length }, }, - - methods: { - // Trigger the entity picker view - async addMemberToCircle() { - await this.$router.push(this.circle.router) - emit('contacts:circles:append', this.circle.id) - }, - - async joinCircle() { - this.loading = true - try { - await joinCircle(this.circle.id) - } catch (error) { - showError(t('contacts', 'Unable to join the circle')) - } finally { - this.loading = false - } - - }, - - async leaveCircle() { - this.loading = true - const member = this.circle.initiator - - try { - await this.$store.dispatch('deleteMemberFromCircle', { - member, - leave: true, - }) - } catch (error) { - console.error('Could not leave the circle', member, error) - showError(t('contacts', 'Could not leave the circle {displayName}', this.circle)) - } finally { - this.loading = false - } - - }, - - async deleteCircle() { - this.loading = true - - try { - 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/RootNavigation.vue b/src/components/AppNavigation/RootNavigation.vue index 675f5750..38398fab 100644 --- a/src/components/AppNavigation/RootNavigation.vue +++ b/src/components/AppNavigation/RootNavigation.vue @@ -69,7 +69,7 @@ </AppNavigationCounter> </AppNavigationItem> - <AppNavigationItem + <AppNavigationCaption id="newgroup" :force-menu="true" :menu-open.sync="isNewGroupMenuOpen" @@ -85,7 +85,7 @@ :placeholder="t('contacts','Group name')" @submit.prevent.stop="createNewGroup" /> </template> - </AppNavigationItem> + </AppNavigationCaption> <!-- Custom groups --> <GroupNavigationItem @@ -101,7 +101,7 @@ icon="" @click="onToggleGroups" /> - <AppNavigationItem + <AppNavigationCaption id="newcircle" :force-menu="true" :menu-open.sync="isNewCircleMenuOpen" @@ -117,7 +117,7 @@ :placeholder="t('contacts','Circle name')" @submit.prevent.stop="createNewCircle" /> </template> - </AppNavigationItem> + </AppNavigationCaption> <!-- Circles --> <CircleNavigationItem @@ -152,6 +152,7 @@ import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation' import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter' import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem' import AppNavigationSettings from '@nextcloud/vue/dist/Components/AppNavigationSettings' +import AppNavigationCaption from '@nextcloud/vue/dist/Components/AppNavigationCaption' import naturalCompare from 'string-natural-compare' @@ -171,6 +172,7 @@ export default { AppNavigationCounter, AppNavigationItem, AppNavigationSettings, + AppNavigationCaption, CircleNavigationItem, GroupNavigationItem, SettingsSection, diff --git a/src/components/CircleDetails.vue b/src/components/CircleDetails.vue index 98e4870c..71a4e5dd 100644 --- a/src/components/CircleDetails.vue +++ b/src/components/CircleDetails.vue @@ -44,44 +44,101 @@ autocorrect="off" spellcheck="false" name="displayname" - @input="debounceUpdateCircle"> + @input="onDisplayNameChangeDebounce"> <!-- org, title --> - <template #subtitle> + <template v-if="!circle.isOwner" #subtitle> + {{ t('contacts', 'Circle owned by {owner}', { owner: circle.owner.displayName}) }} </template> <!-- actions --> <template #actions> + <Actions> + <!-- leave circle --> + <ActionButton + v-if="circle.canLeave" + @click="confirmLeaveCircle"> + {{ t('contacts', 'Leave circle') }} + <ExitToApp slot="icon" + :size="16" + decorative /> + </ActionButton> + + <!-- join circle --> + <ActionButton + v-else-if="!circle.isMember && circle.canJoin" + @click="joinCircle"> + {{ joinButtonTitle }} + <LocationEnter slot="icon" + :size="16" + decorative /> + </ActionButton> + </Actions> + <Actions> + <!-- copy circle link --> + <ActionLink + :href="circleUrl" + :icon="copyLoading ? 'icon-loading-small' : 'icon-public'" + @click.stop.prevent="copyToClipboard(circleUrl)"> + {{ copyButtonText }} + </ActionLink> + </Actions> </template> <!-- menu actions --> <template #actions-menu> + <!-- delete circle --> + <ActionButton + v-if="circle.canDelete" + icon="icon-delete" + @click="confirmDeleteCircle"> + {{ t('contacts', 'Delete') }} + </ActionButton> </template> </DetailsHeader> <section class="circle-details-section"> - <ContentHeading>{{ t('contacts', 'Description') }}</ContentHeading> + <ContentHeading :loading="loadingDescription"> + {{ t('contacts', 'Description') }} + </ContentHeading> - <RichContenteditable class="circle-details-section__description" - :value="circle.description" + <RichContenteditable + :value.sync="circle.description" :auto-complete="onAutocomplete" :maxlength="1024" :multiline="true" - :disabled="loading" - :placeholder="t('contacts', 'Enter a description for the circle')" - @submit="onDescriptionSubmit" /> + :contenteditable="circle.isOwner" + :placeholder="descriptionPlaceholder" + class="circle-details-section__description" + @update:value="onDescriptionChangeDebounce" /> </section> - <section class="circle-details-section"> + <section v-if="circle.isOwner" class="circle-details-section"> <CircleConfigs class="circle-details-section__configs" :circle="circle" /> </section> + + <section v-else> + <slot /> + </section> </AppContentDetails> </template> <script> +import { showError } from '@nextcloud/dialogs' +import debounce from 'debounce' + +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import ActionLink from '@nextcloud/vue/dist/Components/ActionLink' import AppContentDetails from '@nextcloud/vue/dist/Components/AppContentDetails' import Avatar from '@nextcloud/vue/dist/Components/Avatar' import RichContenteditable from '@nextcloud/vue/dist/Components/RichContenteditable' + +import ExitToApp from 'vue-material-design-icons/ExitToApp' +import LocationEnter from 'vue-material-design-icons/LocationEnter' + +import { CircleEdit, editCircle } from '../services/circles.ts' +import CircleActionsMixin from '../mixins/CircleActionsMixin' import DetailsHeader from './DetailsHeader' import CircleConfigs from './CircleDetails/CircleConfigs' import ContentHeading from './CircleDetails/ContentHeading' @@ -90,26 +147,36 @@ export default { name: 'CircleDetails', components: { + ActionButton, + ActionLink, + Actions, AppContentDetails, Avatar, CircleConfigs, ContentHeading, DetailsHeader, + ExitToApp, + LocationEnter, RichContenteditable, }, - props: { - circleId: { - type: String, - required: true, - }, + mixins: [CircleActionsMixin], + + data() { + return { + loadingDescription: false, + } }, computed: { - circle() { - return this.$store.getters.getCircle(this.circleId) + descriptionPlaceholder() { + if (this.circle.description.trim() === '') { + return t('contacts', 'There is no description for this circle') + } + return t('contacts', 'Enter a description for the circle') }, }, + methods: { /** * Autocomplete @mentions on the description @@ -122,8 +189,34 @@ export default { callback([]) }, - onDescriptionSubmit() { - console.info(...arguments) + onDescriptionChangeDebounce: debounce(function() { + this.onDescriptionChange(...arguments) + }, 500), + async onDescriptionChange(description) { + this.loadingDescription = true + try { + await editCircle(this.circle.id, CircleEdit.Description, description) + } catch (error) { + console.error('Unable to edit circle description', description, error) + showError(t('contacts', 'An error happened during description sync')) + } finally { + this.loadingDescription = false + } + }, + + onDisplayNameChangeDebounce: debounce(function() { + this.onDisplayNameChange(...arguments) + }, 500), + async onDisplayNameChange(description) { + this.loadingDescription = true + try { + await editCircle(this.circle.id, CircleEdit.Description, description) + } catch (error) { + console.error('Unable to edit circle description', description, error) + showError(t('contacts', 'An error happened during description sync')) + } finally { + this.loadingDescription = false + } }, }, } @@ -133,17 +226,16 @@ export default { .app-content-details { flex: 1 1 100%; min-width: 0; + padding: 0 80px; } .circle-details-section { - padding: 0 80px; - &:not(:first-of-type) { margin-top: 24px; } &__description { - max-width: 400px; + max-width: 800px; } } </style> diff --git a/src/components/CircleDetails/CircleConfigs.vue b/src/components/CircleDetails/CircleConfigs.vue index bd7b27a8..2db38ab4 100644 --- a/src/components/CircleDetails/CircleConfigs.vue +++ b/src/components/CircleDetails/CircleConfigs.vue @@ -28,31 +28,34 @@ </ContentHeading> <ul class="circle-config__list"> - <CheckboxRadio v-for="(label, config) in configs" + <CheckboxRadioSwitch v-for="(label, config) in configs" :key="'circle-config' + config" :checked="isChecked(config)" + :loading="loading === config" + :disabled="loading !== false" wrapper-element="li" @update:checked="onChange(config, $event)"> {{ label }} - </CheckboxRadio> + </CheckboxRadioSwitch> </ul> </li> </ul> </template> <script> -import CheckboxRadio from '@nextcloud/vue/dist/Components/CheckboxRadio' +import CheckboxRadioSwitch from '@nextcloud/vue/dist/Components/CheckboxRadioSwitch' import ContentHeading from './ContentHeading' import { PUBLIC_CIRCLE_CONFIG } from '../../models/constants.ts' import Circle from '../../models/circle.ts' -import { CircleEdit, editCircle } from '../../services/circles' +import { CircleEdit, editCircle } from '../../services/circles.ts' +import { showError } from '@nextcloud/dialogs' export default { name: 'CircleConfigs', components: { - CheckboxRadio, + CheckboxRadioSwitch, ContentHeading, }, @@ -66,6 +69,8 @@ export default { data() { return { PUBLIC_CIRCLE_CONFIG, + + loading: false, } }, @@ -80,21 +85,29 @@ export default { * @param {boolean} checked checked or not */ async onChange(config, checked) { - console.debug('Circle config', `'${PUBLIC_CIRCLE_CONFIG[config]}'`, 'is set to', checked) + console.debug('Circle config', config, 'is set to', checked) + this.loading = config const prevConfig = this.circle.config - if (checked) { // eslint-disable-next-line vue/no-mutating-props - this.circle.config = prevConfig | config + config = prevConfig | config } else { // eslint-disable-next-line vue/no-mutating-props - this.circle.config = prevConfig & ~config + config = prevConfig & ~config } - const data = await editCircle(this.circle.id, CircleEdit.Config, this.circle.config) - console.info(data) + try { + const circleData = await editCircle(this.circle.id, CircleEdit.Config, config) + // eslint-disable-next-line vue/no-mutating-props + this.circle.config = circleData.config + } catch (error) { + console.error('Unable to edit circle config', prevConfig, config, error) + showError(t('contacts', 'An error happened during the config change')) + } finally { + this.loading = false + } }, }, } diff --git a/src/components/CircleDetails/ContentHeading.vue b/src/components/CircleDetails/ContentHeading.vue index 2f435a0f..4cc03e5f 100644 --- a/src/components/CircleDetails/ContentHeading.vue +++ b/src/components/CircleDetails/ContentHeading.vue @@ -23,17 +23,30 @@ <template> <h3 class="app-content-heading"> <slot /> + <div v-if="loading" class="app-content-heading__loader icon-loading-small" /> </h3> </template> <script> export default { name: 'ContentHeading', + + props: { + loading: { + type: Boolean, + default: false, + }, + }, } </script> <style lang="scss" scoped> .app-content-heading { font-weight: bold; + display: flex; + + &__loader { + margin-left: 8px; + } } </style> diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index f4e53ab4..5c316633 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -771,6 +771,7 @@ export default { .app-content-details { flex: 1 1 100%; min-width: 0; + padding: 0 80px; } // List of all properties diff --git a/src/components/DetailsHeader.vue b/src/components/DetailsHeader.vue index 6723bf10..b2b4d1a2 100644 --- a/src/components/DetailsHeader.vue +++ b/src/components/DetailsHeader.vue @@ -32,7 +32,7 @@ <h2 class="contact-header__infos-title"> <slot name="title" /> </h2> - <div class="contact-header__infos-subtitle"> + <div v-if="$slots.subtitle" class="contact-header__infos-subtitle"> <slot name="subtitle" /> </div> </div> @@ -84,14 +84,12 @@ export default { display: flex; align-items: center; padding: 50px 0 20px; - font-weight: bold; &__avatar { position: relative; - flex: 1 1 var(--avatar-size); - min-width: var(--avatar-size); - max-width: 120px; + flex: 0 0 var(--avatar-size); margin: 10px; + margin-left: 0; display: flex; justify-content: flex-end; } @@ -114,7 +112,7 @@ export default { min-width: 100px; max-width: 100%; margin: 0; - padding: 4px 5px; + padding: 0; white-space: nowrap; text-overflow: ellipsis; border: none; diff --git a/src/components/MembersList/MembersListItem.vue b/src/components/MembersList/MembersListItem.vue index ae50e85c..9eb5a120 100644 --- a/src/components/MembersList/MembersListItem.vue +++ b/src/components/MembersList/MembersListItem.vue @@ -165,7 +165,7 @@ export default { // Object.keys returns those as string .map(level => parseInt(level, 10)) // we cannot set to a level higher than the current user's level - .filter(level => level < this.currentUserLevel) + .filter(level => level <= this.currentUserLevel) // we cannot set to the level this member is already .filter(level => level !== this.source.level) }, @@ -209,6 +209,10 @@ export default { * @returns {string} */ levelChangeLabel(level) { + if (level === MemberLevels.OWNER) { + return t('contacts', 'Promote as sole owner') + } + if (this.source.level < level) { return t('contacts', 'Promote to {level}', { level: CIRCLES_MEMBER_LEVELS[level] }) } @@ -245,6 +249,13 @@ export default { await changeMemberLevel(this.circle.id, this.source.id, level) this.showLevelMenu = false + // If we changed an owner, let's refresh the whole dataset to update all ownership & memberships + if (level === MemberLevels.OWNER) { + await this.$store.dispatch('getCircle', this.circle.id) + await this.$store.dispatch('getCircleMembers', this.circle.id) + return + } + // this.source is a class. We're modifying the class setter, not the prop itself // eslint-disable-next-line vue/no-mutating-props this.source.level = level diff --git a/src/mixins/CircleActionsMixin.js b/src/mixins/CircleActionsMixin.js new file mode 100644 index 00000000..a11cc1c0 --- /dev/null +++ b/src/mixins/CircleActionsMixin.js @@ -0,0 +1,152 @@ +/** + * @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/>. + * + */ +import { emit } from '@nextcloud/event-bus' +import { showError } from '@nextcloud/dialogs' + +import { joinCircle } from '../services/circles.ts' +import Circle from '../models/circle.ts' +import CopyToClipboardMixin from './CopyToClipboardMixin' + +export default { + + props: { + circle: { + type: Circle, + required: true, + }, + }, + + mixins: [CopyToClipboardMixin], + + data() { + return { + loadingAction: false, + } + }, + + computed: { + copyButtonText() { + if (this.copied) { + return this.copySuccess + ? t('contacts', 'Copied') + : t('contacts', 'Could not copy') + } + return t('contacts', 'Copy link') + }, + + circleUrl() { + const route = this.$router.resolve(this.circle.router) + return window.location.origin + route.href + }, + + joinButtonTitle() { + if (this.circle.requireJoinAccept) { + return t('contacts', 'Request to join') + } + return t('contacts', 'Join circle') + }, + }, + + methods: { + confirmLeaveCircle() { + OC.dialogs.confirmDestructive( + t('contacts', 'You are about to leave {circle}.\n Are you sure ?', { + circle: this.circle.displayName, + }), + t('contacts', 'Please confirm circle leave'), + OC.dialogs.YES_NO_BUTTONS, + this.leaveCircle, + true + ) + }, + async leaveCircle(confirm) { + if (!confirm) { + console.debug('Circle leave cancelled') + return + } + + this.loadingAction = true + const member = this.circle.initiator + + try { + await this.$store.dispatch('deleteMemberFromCircle', { + member, + leave: true, + }) + } catch (error) { + console.error('Could not leave the circle', member, error) + showError(t('contacts', 'Could not leave the circle {displayName}', this.circle)) + } finally { + this.loadingAction = false + } + + }, + + async joinCircle() { + this.loadingAction = true + try { + await joinCircle(this.circle.id) + } catch (error) { + showError(t('contacts', 'Unable to join the circle')) + } finally { + this.loadingAction = false + } + + }, + + confirmDeleteCircle() { + OC.dialogs.confirmDestructive( + t('contacts', 'You are about to delete {circle}.\n Are you sure ?', { + circle: this.cir |