From 9acea39cb94222c8d6e4a6c0f7d8dbac5055d1f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6=20=28skjnldsv=29?= Date: Fri, 26 Feb 2021 13:09:23 +0100 Subject: [PATCH 01/17] Move Navigation to dedicated component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John Molakvoæ (skjnldsv) --- .../AppNavigation/GroupsNavigation.vue | 65 +++ .../AppNavigation/RootNavigation.vue | 298 +++++++++++ .../Settings/SettingsAddressbook.vue | 0 .../Settings/SettingsAddressbookShare.vue | 2 +- .../Settings/SettingsAddressbookSharee.vue | 0 .../Settings/SettingsImportContacts.vue | 0 .../Settings/SettingsNewAddressbook.vue | 0 .../Settings/SettingsSortContacts.vue | 0 .../{ => AppNavigation}/SettingsSection.vue | 0 src/components/EntityPicker/EntityPicker.vue | 10 +- src/models/groups.js | 25 + src/models/member.js | 506 ++++++++++++++++++ src/services/circles.js | 34 ++ src/store/circles.js | 188 +++++++ src/store/index.js | 3 + src/views/Contacts.vue | 257 +-------- 16 files changed, 1147 insertions(+), 241 deletions(-) create mode 100644 src/components/AppNavigation/GroupsNavigation.vue create mode 100644 src/components/AppNavigation/RootNavigation.vue rename src/components/{ => AppNavigation}/Settings/SettingsAddressbook.vue (100%) rename src/components/{ => AppNavigation}/Settings/SettingsAddressbookShare.vue (98%) rename src/components/{ => AppNavigation}/Settings/SettingsAddressbookSharee.vue (100%) rename src/components/{ => AppNavigation}/Settings/SettingsImportContacts.vue (100%) rename src/components/{ => AppNavigation}/Settings/SettingsNewAddressbook.vue (100%) rename src/components/{ => AppNavigation}/Settings/SettingsSortContacts.vue (100%) rename src/components/{ => AppNavigation}/SettingsSection.vue (100%) create mode 100644 src/models/groups.js create mode 100644 src/models/member.js create mode 100644 src/services/circles.js create mode 100644 src/store/circles.js diff --git a/src/components/AppNavigation/GroupsNavigation.vue b/src/components/AppNavigation/GroupsNavigation.vue new file mode 100644 index 000000000..b67e3ee8e --- /dev/null +++ b/src/components/AppNavigation/GroupsNavigation.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/components/AppNavigation/RootNavigation.vue b/src/components/AppNavigation/RootNavigation.vue new file mode 100644 index 000000000..6d0b1b494 --- /dev/null +++ b/src/components/AppNavigation/RootNavigation.vue @@ -0,0 +1,298 @@ + + + diff --git a/src/components/Settings/SettingsAddressbook.vue b/src/components/AppNavigation/Settings/SettingsAddressbook.vue similarity index 100% rename from src/components/Settings/SettingsAddressbook.vue rename to src/components/AppNavigation/Settings/SettingsAddressbook.vue diff --git a/src/components/Settings/SettingsAddressbookShare.vue b/src/components/AppNavigation/Settings/SettingsAddressbookShare.vue similarity index 98% rename from src/components/Settings/SettingsAddressbookShare.vue rename to src/components/AppNavigation/Settings/SettingsAddressbookShare.vue index 728a4cfd8..54efd8ba3 100644 --- a/src/components/Settings/SettingsAddressbookShare.vue +++ b/src/components/AppNavigation/Settings/SettingsAddressbookShare.vue @@ -49,7 +49,7 @@ + + diff --git a/src/components/AppContent/ContactsContent.vue b/src/components/AppContent/ContactsContent.vue new file mode 100644 index 000000000..4c84dff11 --- /dev/null +++ b/src/components/AppContent/ContactsContent.vue @@ -0,0 +1,159 @@ + + + + + + diff --git a/src/components/AppNavigation/CircleNavigationItem.vue b/src/components/AppNavigation/CircleNavigationItem.vue new file mode 100644 index 000000000..8e039f43b --- /dev/null +++ b/src/components/AppNavigation/CircleNavigationItem.vue @@ -0,0 +1,183 @@ + + + + diff --git a/src/components/AppNavigation/GroupNavigationItem.vue b/src/components/AppNavigation/GroupNavigationItem.vue new file mode 100644 index 000000000..9545c7b37 --- /dev/null +++ b/src/components/AppNavigation/GroupNavigationItem.vue @@ -0,0 +1,126 @@ + + + + diff --git a/src/components/AppNavigation/RootNavigation.vue b/src/components/AppNavigation/RootNavigation.vue index 6d0b1b494..e80270f49 100644 --- a/src/components/AppNavigation/RootNavigation.vue +++ b/src/components/AppNavigation/RootNavigation.vue @@ -1,3 +1,25 @@ + + @@ -102,54 +144,46 @@ + + diff --git a/src/components/AppNavigation/Settings/SettingsAddressbook.vue b/src/components/AppNavigation/Settings/SettingsAddressbook.vue index 2f8f3b858..c4dac9fdd 100644 --- a/src/components/AppNavigation/Settings/SettingsAddressbook.vue +++ b/src/components/AppNavigation/Settings/SettingsAddressbook.vue @@ -42,7 +42,7 @@ + @click.stop.prevent="copyToClipboard(addressbookUrl)"> {{ copyButtonText }} @@ -99,7 +99,9 @@ import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' import ActionInput from '@nextcloud/vue/dist/Components/ActionInput' import ActionCheckbox from '@nextcloud/vue/dist/Components/ActionCheckbox' import ShareAddressBook from './SettingsAddressbookShare' -import { showError, showSuccess } from '@nextcloud/dialogs' +import { showError } from '@nextcloud/dialogs' + +import CopyToClipboardMixin from '../../../mixins/CopyToClipboardMixin' export default { name: 'SettingsAddressbook', @@ -113,6 +115,8 @@ export default { ShareAddressBook, }, + mixins: [CopyToClipboardMixin], + props: { addressbook: { type: Object, @@ -121,11 +125,9 @@ export default { }, }, }, + data() { return { - copied: false, - copyLoading: false, - copySuccess: false, deleteAddressbookLoading: false, editingName: false, menuOpen: false, @@ -134,6 +136,7 @@ export default { toggleEnabledLoading: false, } }, + computed: { enabled() { return this.addressbook.enabled @@ -147,6 +150,7 @@ export default { hasMultipleAddressbooks() { return this.addressbooks.length > 1 }, + // info tooltip about number of shares sharedWithTooltip() { return this.hasShares @@ -158,6 +162,7 @@ export default { }) : '' // disable the tooltip }, + copyButtonText() { if (this.copied) { return this.copySuccess @@ -166,6 +171,10 @@ export default { } return t('contacts', 'Copy link') }, + + addressbookUrl() { + return window.location.origin + this.addressbook.url + }, }, watch: { menuOpen() { @@ -251,30 +260,6 @@ export default { this.menuOpen = false } }, - async copyLink(event) { - // change to loading status - this.copyLoading = true - - // copy link for addressbook to clipboard - try { - await this.$copyText(window.location.origin + this.addressbook.url) - this.copySuccess = true - this.copied = true - // Notify addressbook was copied - showSuccess(t('contacts', 'Address book copied to clipboard')) - } catch (error) { - this.copySuccess = false - this.copied = true - showError(t('contacts', 'Address book was not copied to clipboard.')) - } finally { - this.copyLoading = false - setTimeout(() => { - // stop loading status regardless of outcome - this.copied = false - this.copySuccess = false - }, 2000) - } - }, }, } diff --git a/src/components/CircleDetails.vue b/src/components/CircleDetails.vue new file mode 100644 index 000000000..ebe173f79 --- /dev/null +++ b/src/components/CircleDetails.vue @@ -0,0 +1,50 @@ + + + + + + + diff --git a/src/components/EntityPicker/ContactsPicker.vue b/src/components/EntityPicker/ContactsPicker.vue new file mode 100644 index 000000000..99e50b25c --- /dev/null +++ b/src/components/EntityPicker/ContactsPicker.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/components/EntityPicker/MembersPicker.vue b/src/components/EntityPicker/MembersPicker.vue new file mode 100644 index 000000000..882a7946f --- /dev/null +++ b/src/components/EntityPicker/MembersPicker.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/src/components/AppNavigation/GroupsNavigation.vue b/src/components/MemberList.vue similarity index 52% rename from src/components/AppNavigation/GroupsNavigation.vue rename to src/components/MemberList.vue index b67e3ee8e..9d69b61ab 100644 --- a/src/components/AppNavigation/GroupsNavigation.vue +++ b/src/components/MemberList.vue @@ -1,8 +1,7 @@ + + diff --git a/src/components/MemberList/MemberListItem.vue b/src/components/MemberList/MemberListItem.vue new file mode 100644 index 000000000..fa2bcddee --- /dev/null +++ b/src/components/MemberList/MemberListItem.vue @@ -0,0 +1,222 @@ + + + + + + diff --git a/src/main.js b/src/main.js index b478cf947..397a35bb6 100644 --- a/src/main.js +++ b/src/main.js @@ -32,7 +32,6 @@ import store from './store' /** GLOBAL COMPONENTS AND DIRECTIVE */ import ClickOutside from 'vue-click-outside' import VTooltip from '@nextcloud/vue/dist/Directives/Tooltip' -import VueClipboard from 'vue-clipboard2' // Dialogs css import '@nextcloud/dialogs/styles/toast.scss' @@ -52,8 +51,6 @@ __webpack_public_path__ = generateFilePath('contacts', '', 'js/') Vue.directive('ClickOutside', ClickOutside) Vue.directive('Tooltip', VTooltip) -Vue.use(VueClipboard) - sync(store, router) Vue.prototype.t = t diff --git a/src/mixins/CopyToClipboardMixin.js b/src/mixins/CopyToClipboardMixin.js new file mode 100644 index 000000000..54e765364 --- /dev/null +++ b/src/mixins/CopyToClipboardMixin.js @@ -0,0 +1,65 @@ +/** + * @copyright Copyright (c) 2018 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ + +import { showError, showSuccess } from '@nextcloud/dialogs' +import Vue from 'vue' +import VueClipboard from 'vue-clipboard2' + +Vue.use(VueClipboard) + +export default { + data() { + return { + copied: false, + copyLoading: false, + copySuccess: false, + } + }, + + methods: { + async copyToClipboard(url) { + // change to loading status + this.copyLoading = true + + // copy link to clipboard + try { + await this.$copyText(url) + this.copySuccess = true + this.copied = true + + // Notify success + showSuccess(t('contacts', 'Link copied to the clipboard')) + } catch (error) { + this.copySuccess = false + this.copied = true + showError(t('contacts', 'Could not copy link to the clipboard.')) + } finally { + this.copyLoading = false + setTimeout(() => { + // stop loading status regardless of outcome + this.copied = false + this.copySuccess = false + }, 2000) + } + }, + }, +} diff --git a/src/models/groups.js b/src/mixins/RouterMixin.js similarity index 76% rename from src/models/groups.js rename to src/mixins/RouterMixin.js index 6b7d5e794..b836ae6c4 100644 --- a/src/models/groups.js +++ b/src/mixins/RouterMixin.js @@ -19,7 +19,17 @@ * along with this program. If not, see . * */ - -export const GROUP_ALL_CONTACTS = t('contacts', 'All contacts') -export const GROUP_NO_GROUP_CONTACTS = t('contacts', 'Not grouped') -export const GROUP_RECENTLY_CONTACTED = t('contactsinteraction', 'Recently contacted') +export default { + computed: { + // router variables + selectedContact() { + return this.$route.params.selectedContact + }, + selectedGroup() { + return this.$route.params.selectedGroup + }, + selectedCircle() { + return this.$route.params.selectedCircle + }, + }, +} diff --git a/src/models/circle.js b/src/models/circle.js new file mode 100644 index 000000000..d4f8ffe4b --- /dev/null +++ b/src/models/circle.js @@ -0,0 +1,307 @@ +/** + * @copyright Copyright (c) 2018 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ + +/** @typedef { import('./member') } Member */ + +import { + MEMBER_LEVEL_MODERATOR, MEMBER_LEVEL_NONE, MEMBER_LEVEL_OWNER, + CIRCLE_CONFIG_REQUEST, CIRCLE_CONFIG_INVITE, CIRCLE_CONFIG_OPEN, +} from './constants' + +import Vue from 'vue' +import Member from './member' + +export default class Circle { + + _data = {} + + /** + * Creates an instance of Contact + * + * @param {Object} data the vcard data as string with proper new lines + * @param {object} circle the addressbook which the contat belongs to + * @memberof Circle + */ + constructor(data) { + if (typeof data !== 'object') { + throw new Error('Invalid circle') + } + + // if no uid set, fail + if (!data.id) { + throw new Error('This circle do not have a proper uid') + } + + this._data = data + this._data.initiator = new Member(data.initiator) + this._data.owner = new Member(data.owner) + this._data.members = {} + } + + // METADATA ----------------------------------------- + /** + * Circle id + * @readonly + * @memberof Circle + * @returns {string} + */ + get id() { + return this._data.id + } + + /** + * Formatted display name + * @readonly + * @memberof Circle + * @returns {string} + */ + get displayName() { + return this._data.displayName + } + + /** + * Circle creation date + * @readonly + * @memberof Circle + * @returns {number} + */ + get creation() { + return this._data.creation + } + + /** + * Circle description + * @readonly + * @memberof Circle + * @returns {string} + */ + get description() { + return this._data.description + } + + /** + * Circle description + * @param {string} text circle description + * @memberof Circle + */ + set description(text) { + this._data.description = text + } + + // MEMBERSHIP ----------------------------------------- + /** + * Circle initiator. This is the current + * user info for this circle + * @readonly + * @memberof Circle + * @returns {Member} + */ + get initiator() { + return this._data.initiator + } + + /** + * Circle ownership + * @readonly + * @memberof Circle + * @returns {Member} + */ + get owner() { + return this._data.owner + } + + /** + * Set new circle owner + * @param {Member} owner circle owner + * @memberof Circle + */ + set owner(owner) { + if (owner.constructor.name !== Member.name) { + throw new Error('Owner must be a Member type') + } + this._data.owner = owner + } + + /** + * Circle members + * @readonly + * @memberof Circle + * @returns {Member[]} + */ + get members() { + return this._data.members + } + + /** + * Define members circle + * @param {Member[]} members the members list + * @memberof Circle + */ + set members(members) { + this._data.members = members + } + + /** + * Add a member to this circle + * @param {Member} member the member to add + */ + addMember(member) { + if (member.constructor.name !== Member.name) { + throw new Error('Member must be a Member type') + } + + const uid = member.id + if (this._data.members[uid]) { + console.warn('Duplicate member overrided', this._data.members[uid], member) + } + Vue.set(this._data.members, uid, member) + } + + /** + * Remove a member from this circle + * @param {Member} member the member to delete + */ + deleteMember(member) { + if (member.constructor.name !== Member.name) { + throw new Error('Member must be a Member type') + } + + const uid = member.id + if (!this._data.members[uid]) { + console.warn('The member was not in this circle. Nothing was done.', member) + } + + // Delete and clear memory + Vue.delete(this._data.members, uid) + } + + // CONFIGS -------------------------------------------- + get settings() { + return this._data.settings + } + + /** + * Circle config + * @readonly + * @memberof Circle + * @returns {number} + */ + get config() { + return this._data.config + } + + /** + * Circle requires invite to be confirmed by moderator or above + * @readonly + * @memberof Circle + * @returns {boolean} + */ + get requireJoinAccept() { + return (this._data.config & CIRCLE_CONFIG_REQUEST) !== 0 + } + + /** + * Circle can be requested to join + * @readonly + * @memberof Circle + * @returns {boolean} + */ + get canJoin() { + return (this._data.config & CIRCLE_CONFIG_OPEN) !== 0 + } + + /** + * Circle requires invite to be accepted by the member + * @readonly + * @memberof Circle + * @returns {boolean} + */ + get requireInviteAccept() { + return (this._data.config & CIRCLE_CONFIG_INVITE) !== 0 + } + + // PERMISSIONS SHORTCUTS ------------------------------ + /** + * Can the initiator add members to this circle? + * @readonly + * @memberof Circle + * @returns {boolean} + */ + get isOwner() { + return this.initiator.level === MEMBER_LEVEL_OWNER + } + + /** + * Is the initiator a member of this circle? + * @readonly + * @memberof Circle + * @returns {boolean} + */ + get isMember() { + return this.initiator.level > MEMBER_LEVEL_NONE + } + + /** + * Can the initiator delete this circle? + * @readonly + * @memberof Circle + * @returns {boolean} + */ + get canDelete() { + return this.isOwner + } + + /** + * Can the initiator add/remove members to this circle? + * @readonly + * @memberof Circle + * @returns {boolean} + */ + get canManageMembers() { + return this.initiator.level >= MEMBER_LEVEL_MODERATOR + } + + // PARAMS --------------------------------------------- + /** + * Vue router param + * @readonly + * @memberof Circle + * @returns {Object} + */ + get router() { + return { + name: 'circle', + params: { selectedCircle: this.id }, + } + } + + /** + * Default javascript fallback + * Used for sorting as well + * @memberof Circle + * @returns {string} + */ + toString() { + return this.displayName + } + +} diff --git a/src/models/constants.js b/src/models/constants.js new file mode 100644 index 000000000..2ec1fd216 --- /dev/null +++ b/src/models/constants.js @@ -0,0 +1,75 @@ +/** + * @copyright Copyright (c) 2021 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +/* eslint-disable no-tabs */ + +// Dynamic groups +export const GROUP_ALL_CONTACTS = t('contacts', 'All contacts') +export const GROUP_NO_GROUP_CONTACTS = t('contacts', 'Not grouped') +export const GROUP_RECENTLY_CONTACTED = t('contactsinteraction', 'Recently contacted') + +// Default max number of items to show in the navigation +export const ELLIPSIS_COUNT = 5 + +// Circles member levels +export const MEMBER_LEVEL_NONE = 0 +export const MEMBER_LEVEL_MEMBER = 1 +export const MEMBER_LEVEL_MODERATOR = 4 +export const MEMBER_LEVEL_ADMIN = 8 +export const MEMBER_LEVEL_OWNER = 9 + +// Circles member types +export const MEMBER_TYPE_CIRCLE = 16 +export const MEMBER_TYPE_USER = 1 +export const MEMBER_TYPE_GROUP = 2 +export const MEMBER_TYPE_MAIL = 3 +export const MEMBER_TYPE_CONTACT = 4 + +// Circles config flags +export const CIRCLE_CONFIG_SYSTEM = 4 // System Circle (not managed by the official front-end). Meaning some config are limited +export const CIRCLE_CONFIG_VISIBLE = 8 // Visible to everyone, if not visible, people have to know its name to be able to find it +export const CIRCLE_CONFIG_OPEN = 16 // Circle is open, people can join +export const CIRCLE_CONFIG_INVITE = 32 // Adding a member generate an invitation that needs to be accepted +export const CIRCLE_CONFIG_REQUEST = 64 // Request to join Circles needs to be confirmed by a moderator +export const CIRCLE_CONFIG_FRIEND = 128 // Members of the circle can invite their friends +export const CIRCLE_CONFIG_PROTECTED = 256 // Password protected to join/request +export const CIRCLE_CONFIG_NO_OWNER = 512 // no owner, only members +export const CIRCLE_CONFIG_HIDDEN = 1024 // hidden from listing, but available as a share entity +export const CIRCLE_CONFIG_BACKEND = 2048 // Fully hidden, only backend Circles +export const CIRCLE_CONFIG_ROOT = 4096 // Circle cannot be inside another Circle +export const CIRCLE_CONFIG_CIRCLE_INVITE = 8192 // Circle must confirm when invited in another circle +export const CIRCLE_CONFIG_FEDERATED = 16384 // Federated + +export const CIRCLES_MEMBER_TYPES = { + [MEMBER_TYPE_CIRCLE]: t('circles', 'Circle'), + [MEMBER_TYPE_USER]: t('circles', 'User'), + [MEMBER_TYPE_GROUP]: t('circles', 'Group'), + [MEMBER_TYPE_MAIL]: t('circles', 'Mail'), + [MEMBER_TYPE_CONTACT]: t('circles', 'Contact'), +} + +export const CIRCLES_MEMBER_LEVELS = { + // [MEMBER_LEVEL_NONE]: t('circles', 'None'), + [MEMBER_LEVEL_MEMBER]: t('circles', 'Member'), + [MEMBER_LEVEL_MODERATOR]: t('circles', 'Moderator'), + [MEMBER_LEVEL_ADMIN]: t('circles', 'Admin'), + [MEMBER_LEVEL_OWNER]: t('circles', 'Owner'), +} diff --git a/src/models/member.js b/src/models/member.js index 1b15bf1de..601a33284 100644 --- a/src/models/member.js +++ b/src/models/member.js @@ -20,487 +20,126 @@ * */ -import { v4 as uuid } from 'uuid' -import ICAL from 'ical.js' -import b64toBlob from 'b64-to-blob' +/** @typedef { import('./circle') } Circle */ -export default class Member { - - /** - * Creates an instance of Contact - * - * @param {string} vcard the vcard data as string with proper new lines - * @param {object} addressbook the addressbook which the contat belongs to - * @memberof Contact - */ - constructor(vcard, addressbook) { - if (typeof vcard !== 'string' || vcard.length === 0) { - throw new Error('Invalid vCard') - } - - let jCal = ICAL.parse(vcard) - if (jCal[0] !== 'vcard') { - throw new Error('Only one contact is allowed in the vcard data') - } - - if (updateDesignSet(jCal)) { - jCal = ICAL.parse(vcard) - } - - this.jCal = jCal - this.addressbook = addressbook - this.vCard = new ICAL.Component(this.jCal) - - // used to state a contact is not up to date with - // the server and cannot be pushed (etag) - this.conflict = false - - // if no uid set, create one - if (!this.vCard.hasProperty('uid')) { - console.info('This contact did not have a proper uid. Setting a new one for ', this) - this.vCard.addPropertyWithValue('uid', uuid()) - } - - // if no rev set, init one - if (!this.vCard.hasProperty('rev')) { - const rev = new ICAL.VCardTime(null, null, 'date-time') - rev.fromUnixTime(Date.now() / 1000) - this.vCard.addPropertyWithValue('rev', rev) - - } - } - - /** - * Update linked addressbook of this contact - * - * @param {Object} addressbook the addressbook - * @memberof Contact - */ - updateAddressbook(addressbook) { - this.addressbook = addressbook - } - - /** - * Return the url - * - * @readonly - * @memberof Contact - */ - get url() { - if (this.dav) { - return this.dav.url - } - return '' - } - - /** - * Return the version - * - * @readonly - * @memberof Contact - */ - get version() { - return this.vCard.getFirstPropertyValue('version') - } - - /** - * Set the version - * - * @param {string} version the version to set - * @memberof Contact - */ - set version(version) { - this.vCard.updatePropertyWithValue('version', version) - } - - /** - * Return the uid - * - * @readonly - * @memberof Contact - */ - get uid() { - return this.vCard.getFirstPropertyValue('uid') - } - - /** - * Set the uid - * - * @param {string} uid the uid to set - * @memberof Contact - */ - set uid(uid) { - this.vCard.updatePropertyWithValue('uid', uid) - } - - /** - * Return the rev - * - * @readonly - * @memberof Contact - */ - get rev() { - return this.vCard.getFirstPropertyValue('rev') - } - - /** - * Set the rev - * - * @param {string} rev the rev to set - * @memberof Contact - */ - set rev(rev) { - this.vCard.updatePropertyWithValue('rev', rev) - } - - /** - * Return the key - * - * @readonly - * @memberof Contact - */ - get key() { - return this.uid + '~' + this.addressbook.id - } +import { MEMBER_TYPE_USER } from './constants' - /** - * Return the photo - * - * @readonly - * @memberof Contact - */ - get photo() { - return this.vCard.getFirstPropertyValue('photo') - } - - /** - * Set the photo - * - * @param {string} photo the photo to set - * @memberof Contact - */ - set photo(photo) { - this.vCard.updatePropertyWithValue('photo', photo) - } - - /** - * Return the photo usable url - * We cannot fetch external url because of csp policies - * - * @readonly - * @memberof Contact - */ - get photoUrl() { - const photo = this.vCard.getFirstProperty('photo') - const encoding = photo.getFirstParameter('encoding') - let photoType = photo.getFirstParameter('type') - let photoB64 = this.photo - - const isBinary = photo.type === 'binary' || encoding === 'b' - - if (photo && photoB64.startsWith('data') && !isBinary) { - // get the last part = base64 - photoB64 = photoB64.split(',').pop() - // 'data:image/png' => 'png' - photoType = photoB64.split(';')[0].split('/') - } - - try { - // Create blob from url - const blob = b64toBlob(photoB64, `image/${photoType}`) - return URL.createObjectURL(blob) - } catch { - console.error('Invalid photo for the following contact. Ignoring...', this.contact, { photoB64, photoType }) - return false - } - } +import Circle from './circle' +import Vue from 'vue' +export default class Member { - /** - * Return the groups - * - * @readonly - * @memberof Contact - */ - get groups() { - const groupsProp = this.vCard.getFirstProperty('categories') - if (groupsProp) { - return groupsProp.getValues() - .filter(group => typeof group === 'string') - .filter(group => group.trim() !== '') - } - return [] - } + /** @typedef Circle */ + _circle + _data = {} /** - * Set the groups + * Creates an instance of Contact * - * @param {Array} groups the groups to set - * @memberof Contact + * @param {Object} data the vcard data as string with proper new lines + * @param {Circle} circle the addressbook which the contat belongs to + * @memberof Member */ - set groups(groups) { - // delete the title if empty - if (isEmpty(groups)) { - this.vCard.removeProperty('categories') - return + constructor(data, circle) { + if (typeof data !== 'object') { + throw new Error('Invalid member') } - if (Array.isArray(groups)) { - let property = this.vCard.getFirstProperty('categories') - if (!property) { - // Init with empty group since we set everything afterwise - property = this.vCard.addPropertyWithValue('categories', '') - } - property.setValues(groups) - } else { - throw new Error('groups data is not an Array') + // if no uid set, fail + if (data.id && typeof data.id !== 'string') { + console.error('This member do not have a proper uid', data) + throw new Error('This member do not have a proper uid') } - } - /** - * Return the groups - * - * @readonly - * @memberof Contact - */ - get kind() { - return this.firstIfArray(this.vCard.getFirstPropertyValue('kind')) - } - - /** - * Return the first email - * - * @readonly - * @memberof Contact - */ - get email() { - return this.firstIfArray(this.vCard.getFirstPropertyValue('email')) + this._circle = circle + this._data = data } /** - * Return the first org - * + * Get the circle of this member * @readonly - * @memberof Contact + * @memberof Member */ - get org() { - return this.firstIfArray(this.vCard.getFirstPropertyValue('org')) + get circle() { + return this._circle } /** - * Set the org - * - * @param {string} org the org data - * @memberof Contact + * Set the circle of this member + * @param {Circle} circle the circle + * @memberof Member */ - set org(org) { - // delete the org if empty - if (isEmpty(org)) { - this.vCard.removeProperty('org') - return + set circle(circle) { + if (circle.constructor.name !== Circle.name) { + throw new Error('circle must be a Circle type') } - this.vCard.updatePropertyWithValue('org', org) + this._circle = circle } /** - * Return the first title - * + * Member id * @readonly - * @memberof Contact - */ - get title() { - return this.firstIfArray(this.vCard.getFirstPropertyValue('title')) - } - - /** - * Set the title - * - * @param {string} title the title - * @memberof Contact + * @memberof Member */ - set title(title) { - // delete the title if empty - if (isEmpty(title)) { - this.vCard.removeProperty('title') - return - } - this.vCard.updatePropertyWithValue('title', title) + get id() { + return this._data.id } /** - * Return the full name - * + * Formatted display name * @readonly - * @memberof Contact - */ - get fullName() { - return this.vCard.getFirstPropertyValue('fn') - } - - /** - * Set the full name - * - * @param {string} name the fn data - * @memberof Contact - */ - set fullName(name) { - this.vCard.updatePropertyWithValue('fn', name) - } - - /** - * Formatted display name based on the order key - * - * @readonly - * @memberof Contact + * @memberof Member */ get displayName() { - const orderKey = store.getters.getOrderKey - const n = this.vCard.getFirstPropertyValue('n') - const fn = this.vCard.getFirstPropertyValue('fn') - const org = this.vCard.getFirstPropertyValue('org') - - // if ordered by last or first name we need the N property - // ! by checking the property we check for null AND empty string - // ! that means we can then check for empty array and be safe not to have - // ! 'xxxx'.join('') !== '' - if (orderKey && n && !isEmpty(n)) { - switch (orderKey) { - case 'firstName': - // Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P. - // -> John Stevenson - if (isEmpty(n[0])) { - return n[1] - } - return n.slice(0, 2).reverse().join(' ') - - case 'lastName': - // Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P. - // -> Stevenson, John - if (isEmpty(n[0])) { - return n[1] - } - return n.slice(0, 2).join(', ') - } - } - // otherwise the FN is enough - if (fn) { - return fn - } - // BUT if no FN property use the N anyway - if (n && !isEmpty(n)) { - // Stevenson;John;Philip,Paul;Dr.;Jr.,M.D.,A.C.P. - // -> John Stevenson - if (isEmpty(n[0])) { - return n[1] - } - return n.slice(0, 2).reverse().join(' ') - } - // LAST chance, use the org ir that's the only thing we have - if (org && !isEmpty(org)) { - // org is supposed to be an array but is also used as plain string - return Array.isArray(org) ? org[0] : org - } - return '' - + return this._data.displayName } /** - * Return the first name if exists - * Returns the displayName otherwise - * + * Member userId * @readonly - * @memberof Contact - * @returns {string} firstName|displayName + * @memberof Member */ - get firstName() { - if (this.vCard.hasProperty('n')) { - // reverse and join - return this.vCard.getFirstPropertyValue('n')[1] - } - return this.displayName + get userId() { + return this._data.userId } /** - * Return the last name if exists - * Returns the displayName otherwise - * + * Member level + * @see file src/models/constants.js * @readonly - * @memberof Contact - * @returns {string} lastName|displayName + * @memberof Member */ - get lastName() { - if (this.vCard.hasProperty('n')) { - // reverse and join - return this.vCard.getFirstPropertyValue('n')[0] - } - return this.displayName + get level() { + return this._data.level } /** - * Return the phonetic first name if exists - * Returns the first name or displayName otherwise - * + * Is the current member a user? * @readonly - * @memberof Contact - * @returns {string} phoneticFirstName|firstName|displayName + * @memberof Member */ - get phoneticFirstName() { - if (this.vCard.hasProperty('x-phonetic-first-name')) { - return this.vCard.getFirstPropertyValue('x-phonetic-first-name') - } - return this.firstName + get isUser() { + return this._data.userType === MEMBER_TYPE_USER } /** - * Return the phonetic last name if exists - * Returns the displayName otherwise - * + * Is the current member without a circle? * @readonly - * @memberof Contact - * @returns {string} lastName|displayName + * @memberof Member */ - get phoneticLastName() { - if (this.vCard.hasProperty('x-phonetic-last-name')) { - return this.vCard.getFirstPropertyValue('x-phonetic-last-name') - } - return this.lastName + get isOrphan() { + return this._circle?.constructor?.name !== 'Circle' } /** - * Return all the properties as Property objects - * - * @readonly - * @memberof Contact - * @returns {Property[]} http://mozilla-comm.github.io/ical.js/api/ICAL.Property.html - */ - get properties() { - return this.vCard.getAllProperties() - } - - /** - * Return an array of formatted properties for the search - * - * @readonly - * @memberof Contact - * @returns {string[]} - */ - get searchData() { - return this.jCal[1].map(x => x[0] + ':' + x[3]) - } - - /** - * Add the contact to the group - * - * @param {string} group the group to add the contact to - * @memberof Contact + * Delete this member and any reference from its circle */ - addToGroup(group) { - if (this.groups.indexOf(group) === -1) { - if (this.groups.length > 0) { - this.vCard.getFirstProperty('categories').setValues(this.groups.concat(group)) - } else { - this.vCard.updatePropertyWithValue('categories', [group]) - } + delete() { + if (this.isOrphan) { + throw new Error('Cannot delete this member as it doesn\'t belong to any circle') } + this.circle.deleteMember(this) + this._circle = undefined + this._data = undefined } } diff --git a/src/router/index.js b/src/router/index.js index 5f6c31b6a..1c2d5d0b8 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -55,6 +55,11 @@ export default new Router({ name: 'group', component: Contacts, }, + { + path: 'circle/:selectedCircle', + name: 'circle', + component: Contacts, + }, { path: ':selectedGroup/:selectedContact', name: 'contact', diff --git a/src/services/circles.js b/src/services/circles.js index 93f480749..ecde326a3 100644 --- a/src/services/circles.js +++ b/src/services/circles.js @@ -23,12 +23,111 @@ import axios from '@nextcloud/axios' import { generateOcsUrl } from '@nextcloud/router' -const baseApi = generateOcsUrl('apps/circles') +const baseApi = generateOcsUrl('apps/circles', 2) /** * Get the circles list without the members + * + * @returns {Array} */ export const getCircles = async function() { const response = await axios.get(baseApi + 'circles') return response.data.ocs.data } + +/** + * Create a new circle + * + * @param {string} name the circle name + * @returns {Object} + */ +export const createCircle = async function(name) { + const response = await axios.post(baseApi + 'circles', { + name, + }) + return response.data.ocs.data +} + +/** + * Delete an existing circle + * + * @param {string} circleId the circle name + * @returns {Object} + */ +export const deleteCircle = async function(circleId) { + const response = await axios.delete(baseApi + `circles/${circleId}`) + return response.data.ocs.data +} + +/** + * Join a circle + * + * @param {string} circleId the circle name + * @returns {Array} + */ +export const joinCircle = async function(circleId) { + const response = await axios.put(baseApi + `circles/${circleId}/join`) + return response.data.ocs.data +} + +/** + * Leave a circle + * + * @param {string} circleId the circle name + * @returns {Array} + */ +export const leaveCircle = async function(circleId) { + const response = await axios.put(baseApi + `circles/${circleId}/leave`) + return response.data.ocs.data +} + +/** + * Get the circle members without the members + * + * @param {string} circleId the circle id + * @returns {Array} + */ +export const getCircleMembers = async function(circleId) { + const response = await axios.get(baseApi + `circles/${circleId}/members`) + return Object.values(response.data.ocs.data) +} + +/** + * Add a circle member + * + * @param {string} circleId the circle id + * @param {string} memberId the member id + * @returns {Array} + */ +export const addMember = async function(circleId, memberId) { + const response = await axios.delete(baseApi + `circles/${circleId}/members/${memberId}`) + return Object.values(response.data.ocs.data) +} + +/** + * Delete a circle member + * + * @param {string} circleId the circle id + * @param {string} memberId the member id + * @returns {Array} + */ +export const deleteMember = async function(circleId, memberId) { + const response = await axios.delete(baseApi + `circles/${circleId}/members/${memberId}`) + return Object.values(response.data.ocs.data) +} + +/** + * change a member level + * @see levels file src/models/constants.js + * + * @param {string} circleId the circle id + * @param {string} memberId the member id + * @param {number} level the new member level + * @returns {Array} + */ +export const changeMemberLevel = async function(circleId, memberId, level) { + const response = await axios.put(baseApi + `circles/${circleId}/members${memberId}}/level`, { + level, + }) + return Object.values(response.data.ocs.data) +} diff --git a/src/store/circles.js b/src/store/circles.js index 415189db9..87dfa7084 100644 --- a/src/store/circles.js +++ b/src/store/circles.js @@ -21,23 +21,15 @@ */ import { showError } from '@nextcloud/dialogs' -import pLimit from 'p-limit' import Vue from 'vue' +import { createCircle, deleteCircle, deleteMember, getCircleMembers, getCircles } from '../services/circles' import Member from '../models/member' -import { getCircles } from '../services/circles' - -const circleModel = { - id: '', - name: '', - owner: {}, - members: [], - initiator: {}, - url: '', -} +import Circle from '../models/circle' const state = { - circles: [], + /** @type {Object.} Circle */ + circles: {}, } const mutations = { @@ -46,37 +38,32 @@ const mutations = { * Add a circle into state * * @param {Object} state the store data - * @param {Object} circle the circle to add + * @param {Circle} circle the circle to add */ addCircle(state, circle) { - // extend the circle to the default model - const newCircle = Object.assign({}, circleModel, circle) - // force reinit of the members object to prevent - // data passed as references - newCircle.members = {} - state.circles.push(newCircle) + Vue.set(state.circles, circle.id, circle) }, /** * Delete circle * * @param {Object} state the store data - * @param {Object} circle the circle to delete + * @param {Circle} circle the circle to delete */ deleteCircle(state, circle) { - state.circles.splice(state.circles.indexOf(circle), 1) + Vue.delete(state.circles, circle.id) }, /** * Rename a circle * - * @param {Object} context the store mutations + * @param {Object} state the store mutations * @param {Object} data destructuring object - * @param {Object} data.circle the circle to rename + * @param {Circle} data.circle the circle to rename * @param {string} data.newName the new name of the addressbook */ - renameCircle(context, { circle, newName }) { - circle = state.circles.find(search => search.id === circle.id) + renameCircle(state, { circle, newName }) { + circle = state.circles[circle.id] circle.displayName = newName }, @@ -85,32 +72,23 @@ const mutations = { * and remove duplicates * * @param {Object} state the store data - * @param {Object} data destructuring object - * @param {Object} data.circle the circle to add the members to - * @param {Member[]} data.members array of contacts to append + * @param {Members[]} members array of members to append */ - appendMembersToCircle(state, { circle, members }) { - circle = state.circles.find(search => search.id === circle.id) - - // convert list into an array and remove duplicate - circle.members = members.reduce((list, member) => { - if (list[member.uid]) { - console.info('Duplicate contact overrided', list[member.uid], member) - } - Vue.set(list, member.uid, member) - return list - }, circle.members) + appendMembersToCircle(state, members) { + members.forEach(member => member.circle.addMember(member)) }, /** * Add a member to a circle and overwrite if duplicate uid * * @param {Object} state the store data - * @param {Member} member the member to add + * @param {Object} data destructuring object + * @param {string} data.circleId the circle to add the members to + * @param {Member} data.member array of contacts to append */ - addMemberToCircle(state, member) { - const circle = state.circles.find(search => search.id === member.circle.id) - Vue.set(circle.members, member.uid, member) + addMemberToCircle(state, { circleId, member }) { + const circle = state.circles[circleId] + circle.addmember(member) }, /** @@ -120,13 +98,14 @@ const mutations = { * @param {Member} member the member to add */ deleteMemberFromCircle(state, member) { - const circle = state.circles.find(search => search.id === member.circle.id) - Vue.delete(circle.members, member.uid) + // Circles dependencies are managed directly from the model + member.delete() }, } const getters = { - getCircles: state => state.circles, + getCircles: state => Object.values(state.circles), + getCircle: state => (id) => state.circles[id], } const actions = { @@ -139,50 +118,92 @@ const actions = { */ async getCircles(context) { const circles = await getCircles() + console.debug(`Retrieved ${circles.length} circle(s)`, circles) - circles.forEach(circle => { - context.commit('addCircle', circle) - }) + circles.map(circle => new Circle(circle)) + .forEach(circle => { + context.commit('addCircle', circle) + }) return circles }, /** - * Append a new address book to array of existing address books + * Retrieve and commit circle members * * @param {Object} context the store mutations - * @param {Object} addressbook The address book to append - * @returns {Promise} + * @param {string} circleId the circle id */ - async appendAddressbook(context, addressbook) { - return client.addressBookHomes[0] - .createAddressBookCollection(addressbook.displayName) - .then((response) => { - addressbook = mapDavCollectionToAddressbook(response) - context.commit('addAddressbook', addressbook) - }) - .catch((error) => { throw error }) + async getCircleMembers(context, circleId) { + const circle = context.getters.getCircle(circleId) + const members = await getCircleMembers(circleId) + + console.debug(`${circleId} have ${members.length} member(s)`, members) + context.commit('appendMembersToCircle', members.map(member => new Member(member, circle))) + }, + + /** + * Create circle + * + * @param {Object} context the store mutations Current context + * @param {string} circleName the circle name + */ + async createCircle(context, circleName) { + try { + const response = await createCircle(circleName) + const circle = new Circle(response) + console.debug('Created circle', circleName, circle) + } catch (error) { + console.error(error) + showError(t('contacts', 'Unable to create circle {circleName}', { circleName })) + } }, /** * Delete circle * * @param {Object} context the store mutations Current context - * @param {Object} circle the circle to delete - * @returns {Promise} + * @param {Circle} circle the circle to delete */ async deleteCircle(context, circle) { - return addressbook.dav - .delete() - .then((response) => { - // delete all the contacts from the store that belong to this addressbook - Object.values(addressbook.contacts) - .forEach(contact => context.commit('deleteContact', contact)) - // then delete the addressbook - context.commit('deleteAddressbook', addressbook) - }) - .catch((error) => { throw error }) + try { + await deleteCircle(circle.id) + console.debug('Created circle', circle.displayName, circle) + } catch (error) { + console.error(error) + showError(t('contacts', 'Unable to create circle {displayName}', circle)) + } }, + + /** + * Add a member to a circle + * + * @param {Object} context the store mutations Current context + * @param {Object} data destructuring object + * @param {string} data.circleId the circle to manage + * @param {string} data.memberId the member to add + */ + async addMemberToCircle(context, { circleId, memberId }) { + await this.addMember(circleId, memberId) + console.debug('Added member', circleId, memberId) + }, + + /** + * Delete a member from a circle + * + * @param {Object} context the store mutations Current context + * @param {Member} member the member to remove + */ + async deleteMemberFromCircle(context, member) { + const circleId = member.circle.id + const memberId = member.id + await deleteMember(circleId, memberId) + + // success, let's remove from store + context.commit('deleteMemberFromCircle', member) + console.debug('Deleted member', circleId, memberId) + }, + } export default { state, mutations, getters, actions } diff --git a/src/utils/fileUtils.js b/src/utils/fileUtils.js new file mode 100644 index 000000000..61cadc320 --- /dev/null +++ b/src/utils/fileUtils.js @@ -0,0 +1,48 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +import camelcase from 'camelcase' +import { isNumber } from './numberUtils' + +export const formatObject = function(obj) { + const data = {} + + Object.keys(obj).forEach(key => { + const data = obj[key] + + // flatten object if any + if (!!data && typeof data === 'object') { + Object.assign(data, formatObject(data)) + } else { + // format key and add it to the data + if (data === 'false') { + data[camelcase(key)] = false + } else if (data === 'true') { + data[camelcase(key)] = true + } else { + data[camelcase(key)] = isNumber(data) + ? Number(data) + : data + } + } + }) + return data +} diff --git a/src/utils/numberUtils.js b/src/utils/numberUtils.js new file mode 100644 index 000000000..0c3a96e5a --- /dev/null +++ b/src/utils/numberUtils.js @@ -0,0 +1,30 @@ +/** + * @copyright Copyright (c) 2019 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ + +const isNumber = function(num) { + if (!num) { + return false + } + return Number(num).toString() === num.toString() +} + +export { isNumber } diff --git a/src/views/Contacts.vue b/src/views/Contacts.vue index 84610f23a..5b9dfa537 100644 --- a/src/views/Contacts.vue +++ b/src/views/Contacts.vue @@ -32,12 +32,11 @@ + :selected-contact="selectedContact"> - - -
- - {{ t('contacts', 'Loading contacts …') }} - -
- -
- - {{ t('contacts', 'There are no contacts yet') }} - - -
- -
- - {{ t('contacts', 'There are no contacts in this group') }} - - -
- -
- - - - - -
-
+ + + - - - - - - + diff --git a/src/components/AppNavigation/Settings/SettingsImportContacts.vue b/src/components/AppNavigation/Settings/SettingsImportContacts.vue index 10ffa9f8e..4d6d309d7 100644 --- a/src/components/AppNavigation/Settings/SettingsImportContacts.vue +++ b/src/components/AppNavigation/Settings/SettingsImportContacts.vue @@ -90,7 +90,7 @@ import { encodePath } from '@nextcloud/paths' import { getCurrentUser } from '@nextcloud/auth' import { generateRemoteUrl } from '@nextcloud/router' import { getFilePickerBuilder } from '@nextcloud/dialogs' -import axios from 'axios' +import axios from '@nextcloud/axios' const CancelToken = axios.CancelToken diff --git a/src/components/EntityPicker/ContactsPicker.vue b/src/components/EntityPicker/ContactsPicker.vue index 99e50b25c..f388298cd 100644 --- a/src/components/EntityPicker/ContactsPicker.vue +++ b/src/components/EntityPicker/ContactsPicker.vue @@ -9,7 +9,7 @@ diff --git a/src/components/EntityPicker/EntityPicker.vue b/src/components/EntityPicker/EntityPicker.vue index 8fb25f239..878d9fb79 100644 --- a/src/components/EntityPicker/EntityPicker.vue +++ b/src/components/EntityPicker/EntityPicker.vue @@ -35,17 +35,17 @@ :placeholder="t('contacts', 'Search {types}', {types: searchPlaceholderTypes})" class="entity-picker__search-input" type="search" - @change="onSearch"> + @input="onSearch"> @@ -68,7 +68,7 @@ :data-sources="availableEntities" :data-component="EntitySearchResult" :estimate-size="44" - :extra-props="{selection, onClick: onPick}" /> + :extra-props="{selection: selectionSet, onClick: onPick}" /> {{ t('contacts', 'No results') }} @@ -92,9 +92,10 @@ diff --git a/src/components/MemberList/MemberListItem.vue b/src/components/MembersList/MembersListItem.vue similarity index 67% rename from src/components/MemberList/MemberListItem.vue rename to src/components/MembersList/MembersListItem.vue index f47495f62..19e679066 100644 --- a/src/components/MemberList/MemberListItem.vue +++ b/src/components/MembersList/MembersListItem.vue @@ -22,13 +22,14 @@ + From bb5f38e9231b659f348fbd83422af0d65194037b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6=20=28skjnldsv=29?= Date: Tue, 20 Apr 2021 17:04:35 +0200 Subject: [PATCH 07/17] Circle details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John Molakvoæ (skjnldsv) --- package.json | 8 +- .../AppNavigation/CircleNavigationItem.vue | 3 +- src/components/CircleDetails.vue | 141 ++++++- .../CircleDetails/CircleConfigs.vue | 110 ++++++ .../CircleDetails/ContentHeading.vue | 39 ++ src/components/ContactDetails.vue | 359 +++++++----------- .../ContactDetails/ContactDetailsAvatar.vue | 138 ++++--- src/components/DetailsHeader.vue | 165 ++++++++ src/components/MemberList.vue | 47 ++- .../MembersList/MembersListItem.vue | 32 +- src/models/circle.d.ts | 8 +- src/models/circle.ts | 13 +- src/models/constants.d.ts | 10 + src/models/constants.ts | 42 ++ src/models/member.d.ts | 10 +- src/models/member.ts | 16 +- src/services/circles.d.ts | 16 + src/services/circles.ts | 22 +- src/store/circles.js | 10 +- 19 files changed, 829 insertions(+), 360 deletions(-) create mode 100644 src/components/CircleDetails/CircleConfigs.vue create mode 100644 src/components/CircleDetails/ContentHeading.vue create mode 100644 src/components/DetailsHeader.vue diff --git a/package.json b/package.json index d96d2daec..dee4be158 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,7 @@ "@nextcloud/moment": "^1.1.1", "@nextcloud/paths": "^1.1.2", "@nextcloud/router": "^2.0.0", - "@nextcloud/vue": "^3.9.0", - "axios": "^0.21.1", + "@nextcloud/vue": "^4.0.0-alpha.1", "b64-to-blob": "^1.2.19", "camelcase": "^5.3.1", "cdav-library": "git+https://github.com/nextcloud/cdav-library.git", @@ -83,12 +82,15 @@ "@nextcloud/browserslist-config": "^2.1.0", "@nextcloud/eslint-config": "^5.1.0", "@nextcloud/eslint-plugin": "^2.0.0", + "@nextcloud/typings": "^1.0.0", "@nextcloud/webpack-vue-config": "^4.0.3", + "@typescript-eslint/parser": "^4.22.0", "babel-eslint": "^10.1.0", "babel-loader": "^8.2.2", "css-loader": "^4.3.0", "eslint": "^7.27.0", "eslint-config-standard": "^16.0.3", + "eslint-import-resolver-typescript": "^2.4.0", "eslint-loader": "^4.0.2", "eslint-plugin-import": "^2.23.3", "eslint-plugin-node": "^11.1.0", @@ -105,6 +107,8 @@ "stylelint-config-recommended-scss": "^4.2.0", "stylelint-scss": "^3.19.0", "stylelint-webpack-plugin": "^2.1.1", + "ts-loader": "^8.1.0", + "typescript": "^4.2.4", "url-loader": "^4.1.1", "vue-loader": "^15.9.7", "vue-template-compiler": "^2.6.12", diff --git a/src/components/AppNavigation/CircleNavigationItem.vue b/src/components/AppNavigation/CircleNavigationItem.vue index 26838678c..eadea9a72 100644 --- a/src/components/AppNavigation/CircleNavigationItem.vue +++ b/src/components/AppNavigation/CircleNavigationItem.vue @@ -93,7 +93,7 @@ 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 { deleteCircle, joinCircle } from '../../services/circles.ts' +import { joinCircle } from '../../services/circles.ts' import { showError } from '@nextcloud/dialogs' import Circle from '../../models/circle.ts' import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin' @@ -194,7 +194,6 @@ export default { 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')) diff --git a/src/components/CircleDetails.vue b/src/components/CircleDetails.vue index ebe173f79..98e4870cb 100644 --- a/src/components/CircleDetails.vue +++ b/src/components/CircleDetails.vue @@ -1,39 +1,101 @@ + - @copyright Copyright (c) 2021 John Molakvoæ + - + - @author John Molakvoæ + - + - @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 . + - + --> diff --git a/src/components/CircleDetails/CircleConfigs.vue b/src/components/CircleDetails/CircleConfigs.vue new file mode 100644 index 000000000..bd7b27a8e --- /dev/null +++ b/src/components/CircleDetails/CircleConfigs.vue @@ -0,0 +1,110 @@ + + + + + + + diff --git a/src/components/CircleDetails/ContentHeading.vue b/src/components/CircleDetails/ContentHeading.vue new file mode 100644 index 000000000..2f435a0fb --- /dev/null +++ b/src/components/CircleDetails/ContentHeading.vue @@ -0,0 +1,39 @@ + + + + + + + diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index 0dbdfd203..f4e53ab47 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -32,56 +32,54 @@ @@ -378,4 +385,11 @@ export default { .app-navigation__collapse ::v-deep a { color: var(--color-text-maxcontrast) } + +// Change icon opacity for a better soothing visual +.app-navigation-entry ::v-deep { + .app-navigation-entry-icon.icon-group { + opacity: .6; + } +} diff --git a/src/components/CircleDetails.vue b/src/components/CircleDetails.vue index 79cac2b23..d0680e72f 100644 --- a/src/components/CircleDetails.vue +++ b/src/components/CircleDetails.vue @@ -50,55 +50,19 @@ - - - - - -
+ + + {{ copyButtonText }} + +
+ +
{{ t('contacts', 'Description') }} @@ -121,6 +85,26 @@
+ +
+ + + + + +
@@ -128,15 +112,11 @@ 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 Logout from 'vue-material-design-icons/Logout' import { CircleEdit, editCircle } from '../services/circles.ts' import CircleActionsMixin from '../mixins/CircleActionsMixin' @@ -148,16 +128,12 @@ export default { name: 'CircleDetails', components: { - ActionButton, - ActionLink, - Actions, AppContentDetails, Avatar, CircleConfigs, ContentHeading, DetailsHeader, - ExitToApp, - LocationEnter, + Logout, RichContenteditable, }, @@ -176,6 +152,17 @@ export default { } return t('contacts', 'Enter a description for the circle') }, + + isEmptyDescription() { + return this.circle.description.trim() === '' + }, + + showDescription() { + if (this.circle.isOwner) { + return true + } + return !this.isEmptyDescription + }, }, methods: { @@ -227,7 +214,7 @@ export default { .app-content-details { flex: 1 1 100%; min-width: 0; - padding: 0 80px; + padding: 0 80px 80px 80px; } .circle-details-section { @@ -239,4 +226,36 @@ export default { max-width: 800px; } } + +// TODO: replace by button component when available +button, +.circle-details__action-copy-link { + height: 44px; + display: inline-flex; + justify-content: center; + align-items: center; + text-align: left; + span { + margin-right: 10px; + } + + &[class*='icon-'] { + padding-left: 44px; + background-position: 16px center; + + } +} + +.circle-details__action-delete { + background-color: var(--color-error); + color: white; + border-width: 2px; + border-color: var(--color-error) !important; + + &:hover, + &:focus { + background-color: var(--color-main-background); + color: var(--color-error); + } +} diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index 5c3166331..65b8653fa 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -243,6 +243,7 @@ import { VueMasonryPlugin } from 'vue-masonry' 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 EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' import Modal from '@nextcloud/vue/dist/Components/Modal' import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' @@ -253,7 +254,6 @@ import AddNewProp from './ContactDetails/ContactDetailsAddNewProp' import ContactAvatar from './ContactDetails/ContactDetailsAvatar' import ContactProperty from './ContactDetails/ContactDetailsProperty' import DetailsHeader from './DetailsHeader' -import EmptyContent from './EmptyContent' import PropertyGroups from './Properties/PropertyGroups' import PropertyRev from './Properties/PropertyRev' import PropertySelect from './Properties/PropertySelect' @@ -771,7 +771,7 @@ export default { .app-content-details { flex: 1 1 100%; min-width: 0; - padding: 0 80px; + padding: 0 80px 80px 80px; } // List of all properties diff --git a/src/components/EmptyContent.vue b/src/components/EmptyContent.vue deleted file mode 100644 index 5198f8ced..000000000 --- a/src/components/EmptyContent.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - diff --git a/src/components/EntityPicker/EntityPicker.vue b/src/components/EntityPicker/EntityPicker.vue index 0ad48a76e..5b05bd33f 100644 --- a/src/components/EntityPicker/EntityPicker.vue +++ b/src/components/EntityPicker/EntityPicker.vue @@ -37,55 +37,58 @@ @input="onSearch"> - - - - - - + {{ t('contacts', 'Loading …') }} - - - {{ t('contacts', 'List is empty') }} - - - - - - - {{ t('contacts', 'No results') }} - - -
- - -
+ diff --git a/src/components/EntityPicker/NewCircleIntro.vue b/src/components/EntityPicker/NewCircleIntro.vue new file mode 100644 index 000000000..ab56faf03 --- /dev/null +++ b/src/components/EntityPicker/NewCircleIntro.vue @@ -0,0 +1,230 @@ + + + + + + + diff --git a/src/components/MemberList.vue b/src/components/MemberList.vue index 2aa21ce09..032b7babb 100644 --- a/src/components/MemberList.vue +++ b/src/components/MemberList.vue @@ -21,7 +21,19 @@ -->