Circles listing base

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2021-02-26 15:37:35 +01:00
parent 9acea39cb9
commit d6030761c3
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
27 changed files with 2346 additions and 922 deletions

View File

@ -1,6 +1,7 @@
module.exports = {
plugins: [
'@babel/plugin-syntax-dynamic-import',
['@babel/plugin-proposal-class-properties', { loose: true }],
],
presets: [
[

View File

@ -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)

1
img/circles.svg Normal file
View File

@ -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>

After

Width:  |  Height:  |  Size: 480 B

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
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>
<AppNavigationItem
id="newgroup"
:force-menu="true"
:menu-open.sync="isNewGroupMenuOpen"
:title="t('contacts', '+ New group')"
:title="t('contacts', 'Groups')"
menu-icon="icon-add"
@click.prevent.stop="toggleNewGroupMenu">
<template slot="actions">
@ -90,6 +86,52 @@
@submit.prevent.stop="createNewGroup" />
</template>
</AppNavigationItem>
<!-- Custom groups -->
<GroupNavigationItem
v-for="group in ellipsisGroupsMenu"
:key="group.key"
:group="group" />
<!-- Toggle groups ellipsis -->
<AppNavigationItem
v-if="groupsMenu.length > ELLIPSIS_COUNT"
:title="collapseGroupsTitle"
class="app-navigation__collapse"
icon=""
@click="onToggleGroups" />
<AppNavigationItem
id="newcircle"
:force-menu="true"
:menu-open.sync="isNewCircleMenuOpen"
:title="t('contacts', 'Circles')"
menu-icon="icon-add"
@click.prevent.stop="toggleNewCircleMenu">
<template slot="actions">
<ActionText :icon="createCircleError ? 'icon-error' : 'icon-contacts-dark'">
{{ createCircleError ? createCircleError : t('contacts', 'Create a new circle') }}
</ActionText>
<ActionInput
icon=""
:placeholder="t('contacts','Circle name')"
@submit.prevent.stop="createNewCircle" />
</template>
</AppNavigationItem>
<!-- Circles -->
<CircleNavigationItem
v-for="circle in ellipsisCirclesMenu"
:key="circle.key"
:circle="circle" />
<!-- Toggle circles ellipsis -->
<AppNavigationItem
v-if="circlesMenu.length > ELLIPSIS_COUNT"
:title="collapseCirclesTitle"
class="app-navigation__collapse"
icon=""
@click="onToggleCircles" />
</template>
<!-- settings -->
@ -102,54 +144,46 @@
</template>
<script>
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED } from '../../models/groups'
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED, ELLIPSIS_COUNT } from '../../models/constants'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import ActionInput from '@nextcloud/vue/dist/Components/ActionInput'
import ActionText from '@nextcloud/vue/dist/Components/ActionText'
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 AppNavigationSpacer from '@nextcloud/vue/dist/Components/AppNavigationSpacer'
import download from 'downloadjs'
import moment from 'moment'
import naturalCompare from 'string-natural-compare'
import CircleNavigationItem from './CircleNavigationItem'
import GroupNavigationItem from './GroupNavigationItem'
import SettingsSection from './SettingsSection'
import isContactsInteractionEnabled from '../../services/isContactsInteractionEnabled'
import RouterMixin from '../../mixins/RouterMixin'
export default {
name: 'RootNavigation',
components: {
ActionButton,
ActionInput,
ActionText,
AppNavigation,
AppNavigationCounter,
AppNavigationItem,
AppNavigationSettings,
AppNavigationSpacer,
CircleNavigationItem,
GroupNavigationItem,
SettingsSection,
},
mixins: [RouterMixin],
props: {
loading: {
type: Boolean,
default: true,
},
selectedGroup: {
type: String,
default: undefined,
},
selectedContact: {
type: String,
default: undefined,
},
contactsList: {
type: Array,
required: true,
@ -158,19 +192,31 @@ export default {
data() {
return {
ELLIPSIS_COUNT,
GROUP_ALL_CONTACTS,
GROUP_NO_GROUP_CONTACTS,
GROUP_RECENTLY_CONTACTED,
// Create group
// create group
isNewGroupMenuOpen: false,
createGroupError: null,
// create circle
isNewCircleMenuOpen: false,
createCircleError: null,
isContactsInteractionEnabled,
collapsedGroups: true,
collapsedCircles: true,
}
},
computed: {
// store variables
circles() {
return this.$store.getters.getCircles
},
contacts() {
return this.$store.getters.getContacts
},
@ -181,14 +227,15 @@ export default {
return this.$store.getters.getSortedContacts
},
// list all the contacts that doesn't have a group
ungroupedContacts() {
return this.sortedContacts.filter(contact => this.contacts[contact.key].groups && this.contacts[contact.key].groups.length === 0)
},
// generate groups menu from groups store
// generate groups menu from the groups store
groupsMenu() {
const menu = this.groups.map(group => {
return Object.assign(group, {
return Object.assign({}, group, {
id: group.name.replace(' ', '_'),
key: group.name.replace(' ', '_'),
router: {
@ -208,70 +255,53 @@ export default {
return menu
},
ellipsisGroupsMenu() {
if (this.collapsedGroups) {
return this.groupsMenu.slice(0, ELLIPSIS_COUNT)
}
return this.groupsMenu
},
// generate circles menu from the circles store
circlesMenu() {
const menu = this.circles
menu.sort((a, b) => naturalCompare(a.toString(), b.toString(), { caseInsensitive: true }))
return menu
},
ellipsisCirclesMenu() {
if (this.collapsedCircles) {
return this.circlesMenu.slice(0, ELLIPSIS_COUNT)
}
return this.circlesMenu
},
// Recently contacted data
recentlyContactedContacts() {
return this.groups.find(group => group.name === GROUP_RECENTLY_CONTACTED)
},
// Titles for the ellipsis toggle buttons
collapseGroupsTitle() {
return this.collapsedGroups
? t('contacts', 'Show all groups')
: t('contacts', 'Collapse groups')
},
collapseCirclesTitle() {
return this.collapsedCircles
? t('contacts', 'Show all circles')
: t('contacts', 'Collapse circles')
},
},
methods: {
/**
* 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')
})
},
/**
* Forward the addContactsToGroup event to the parent
*/
addContactsToGroup() {
this.$emit('addContactsToGroup', ...arguments)
},
toggleNewGroupMenu() {
this.isNewGroupMenuOpen = !this.isNewGroupMenuOpen
},
createNewGroup(e) {
const input = e.target.querySelector('input[type=text]')
const groupName = input.value.trim()
console.debug('Creating new group', groupName)
// Check if already exists
if (this.groups.find(group => group.name === groupName)) {
@ -293,6 +323,57 @@ export default {
},
})
},
// Ellipsis item toggles
onToggleGroups() {
this.collapsedGroups = !this.collapsedGroups
},
onToggleCircles() {
this.collapsedCircles = !this.collapsedCircles
},
toggleNewCircleMenu() {
this.isNewCircleMenuOpen = !this.isNewCircleMenuOpen
},
async createNewCircle(e) {
const input = e.target.querySelector('input[type=text]')
const circleName = input.value.trim()
console.debug('Creating new circle', circleName)
// Check if already exists
if (this.circles.find(circle => circle.name === circleName)) {
this.createGroupError = t('contacts', 'This circle already exists')
return
}
this.createCircleError = null
const circleId = await this.$store.dispatch('createCircle', circleName)
this.isNewCircleMenuOpen = false
// Select group
this.$router.push({
name: 'circle',
params: {
selectedCircle: circleId,
},
})
},
},
}
</script>
<style lang="scss" scoped>
#newgroup,
#newcircle {
margin-top: 22px;
/deep/ a {
color: var(--color-text-maxcontrast)
}
}
.app-navigation__collapse /deep/ a {
color: var(--color-text-maxcontrast)
}
</style>

View File

@ -42,7 +42,7 @@
<ActionLink
:href="addressbook.url"
:icon="copyLoading ? 'icon-loading-small' : 'icon-public'"
@click.stop.prevent="copyLink">
@click.stop.prevent="copyToClipboard(addressbookUrl)">
{{ copyButtonText }}
</ActionLink>
@ -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)
}
},
},
}
</script>

View File

@ -0,0 +1,50 @@
<!--
- @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>
<AppContentDetails>
{{ circleId }}
</AppContentDetails>
</template>
<script>
import AppContentDetails from '@nextcloud/vue/dist/Components/AppContentDetails'
export default {
name: 'CircleDetails',
components: {
AppContentDetails,
},
props: {
circleId: {
type: String,
required: true,
},
},
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,187 @@
<template>
<!-- Bulk contacts edit modal -->
<Modal v-if="isProcessing || isProcessDone"
:clear-view-delay="-1"
:can-close="isProcessDone"
@close="closeProcess">
<AddToGroupView v-bind="processStatus" @close="closeProcess" />
</Modal>
<!-- contacts picker -->
<EntityPicker v-else-if="showPicker"
:confirm-label="t('contacts', 'Add to group {group}', { group: pickerforGroup.name})"
:data-types="pickerTypes"
:data-set="pickerData"
@close="onContactPickerClose"
@submit="onContactPickerPick" />
</template>
<script>
import { subscribe } from '@nextcloud/event-bus'
import pLimit from 'p-limit'
import Modal from '@nextcloud/vue/dist/Components/Modal'
import AddToGroupView from '../../views/Processing/AddToGroupView'
import appendContactToGroup from '../../services/appendContactToGroup'
import EntityPicker from './EntityPicker'
export default {
name: 'ContactsPicker',
components: {
AddToGroupView,
EntityPicker,
Modal,
},
data() {
return {
// Entity picker
showPicker: false,
pickerforGroup: null,
pickerData: [],
pickerTypes: [{
id: 'contact',
label: t('contacts', 'Contacts'),
}],
// Bulk processing
isProcessing: false,
isProcessDone: false,
processStatus: {
failed: 0,
progress: 0,
success: 0,
total: 0,
name: '',
},
}
},
computed: {
contacts() {
return this.$store.getters.getContacts
},
groups() {
return this.$store.getters.getGroups
},
sortedContacts() {
return this.$store.getters.getSortedContacts
},
},
mounted() {
// Watch for a add-to-group event
subscribe('contacts:group:append', this.addContactsToGroup)
},
methods: {
// Bulk contacts group management handlers
addContactsToGroup(group) {
console.debug('Contacts picker opened for group', group)
// Get the full group if we provided the group name only
if (typeof group === 'string') {
group = this.groups.find(a => a.name === group)
if (!group) {
console.error('Cannot add contact to an undefined group', group)
return
}
}
// Init data set
this.pickerData = this.sortedContacts
.map(({ key }) => {
const contact = this.contacts[key]
return {
id: contact.key,
label: contact.displayName,
type: 'contact',
readOnly: contact.addressbook.readOnly,
groups: contact.groups,
}
})
// No read only contacts
.filter(contact => !contact.readOnly)
// No contacts already present in group
.filter(contact => contact.groups.indexOf(group.name) === -1)
this.showPicker = true
this.pickerforGroup = group
},
onContactPickerClose() {
this.pickerData = []
this.showPicker = false
},
onContactPickerPick(selection) {
console.debug('Adding', selection, 'to group', this.pickerforGroup)
const groupName = this.pickerforGroup.name
this.isProcessing = true
this.showPicker = false
this.processStatus.total = selection.length
this.processStatus.name = this.pickerforGroup.name
this.processStatus.progress = 0
this.processStatus.failed = 0
// max simultaneous requests
const limit = pLimit(3)
const requests = []
// create the array of requests to send
selection.map(async entity => {
try {
// Get contact
const contact = this.contacts[entity.id]
// push contact to server and use limit
requests.push(limit(() => appendContactToGroup(contact, groupName)
.then((response) => {
this.$store.dispatch('addContactToGroup', { contact, groupName })
this.processStatus.progress++
this.processStatus.success++
})
.catch((error) => {
this.processStatus.progress++
this.processStatus.error++
console.error(error)
})
))
} catch (e) {
console.error(e)
}
})
Promise.all(requests).then(() => {
this.isProcessDone = true
this.showPicker = false
// Auto close after 3 seconds if no errors
if (this.processStatus.failed === 0) {
setTimeout(this.closeProcess, 3000)
}
})
},
closeProcess() {
this.pickerforGroup = null
this.isProcessing = false
this.isProcessDone = false
// Reset
this.processStatus.failed = 0
this.processStatus.progress = 0
this.processStatus.success = 0
this.processStatus.total = 0
},
},
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,185 @@
<template>
<!-- Bulk contacts edit modal -->
<Modal v-if="isProcessing || isProcessDone"
:clear-view-delay="-1"
:can-close="isProcessDone"
@close="closeProcess">
<AddToGroupView v-bind="processStatus" @close="closeProcess" />
</Modal>
<!-- contacts picker -->
<EntityPicker v-else-if="showPicker"
:confirm-label="t('contacts', 'Add to circle {circle}', { circle: pickerforCircle.name})"
:data-types="pickerTypes"
:data-set="pickerData"
@close="onContactPickerClose"
@submit="onContactPickerPick" />
</template>
<script>
import { subscribe } from '@nextcloud/event-bus'
import pLimit from 'p-limit'
import Modal from '@nextcloud/vue/dist/Components/Modal'
import AddToGroupView from '../../views/Processing/AddToGroupView'
import appendContactToGroup from '../../services/appendContactToGroup'
import EntityPicker from './EntityPicker'
export default {
name: 'MembersPicker',
components: {
AddToGroupView,
EntityPicker,
Modal,
},
data() {
return {
// Entity picker
showPicker: false,
pickerforCircle: null,
pickerData: [],
pickerTypes: [{
id: 'contact',
label: t('contacts', 'Contacts'),
}],
// Bulk processing
isProcessing: false,
isProcessDone: false,
processStatus: {
failed: 0,
progress: 0,
success: 0,
total: 0,
name: '',
},
}
},
computed: {
contacts() {
return this.$store.getters.getContacts
},
groups() {
return this.$store.getters.getGroups
},
sortedContacts() {
return this.$store.getters.getSortedContacts
},
},
mounted() {
// Watch for a add-to-group event
subscribe('contacts:circles:append', this.addMemberToCircle)
},
methods: {
// Bulk contacts group management handlers
addMemberToCircle(circleId) {
const circle = this.$store.getters.getCircle(circleId)
console.debug('Member picker opened for circle', circleId)
// Get the full group if we provided the group name only
if (circle?.id !== circleId) {
console.error('Cannot add member to an undefined circle', circle, circleId)
return
}
// Init data set
this.pickerData = this.sortedContacts
.map(({ key }) => {
const contact = this.contacts[key]
return {
id: contact.key,
label: contact.displayName,
type: 'contact',
readOnly: contact.addressbook.readOnly,
groups: contact.groups,
}
})
// No read only contacts
.filter(contact => !contact.readOnly)
// No contacts already present in group
.filter(contact => contact.groups.indexOf(circle.name) === -1)
this.showPicker = true
this.pickerforCircle = circle
},
onContactPickerClose() {
this.pickerData = []
this.showPicker = false
},
onContactPickerPick(selection) {
console.debug('Adding', selection, 'to circle', this.pickerforCircle)
const groupName = this.pickerforCircle.name
this.isProcessing = true
this.showPicker = false
this.processStatus.total = selection.length
this.processStatus.name = this.pickerforCircle.name
this.processStatus.progress = 0
this.processStatus.failed = 0
// max simultaneous requests
const limit = pLimit(3)
const requests = []
// create the array of requests to send
selection.map(async entity => {
try {
// Get contact
const contact = this.contacts[entity.id]
// push contact to server and use limit
requests.push(limit(() => appendContactToGroup(contact, groupName)
.then((response) => {
this.$store.dispatch('addContactToGroup', { contact, groupName })
this.processStatus.progress++
this.processStatus.success++
})
.catch((error) => {
this.processStatus.progress++
this.processStatus.error++
console.error(error)
})
))
} catch (e) {
console.error(e)
}
})
Promise.all(requests).then(() => {
this.isProcessDone = true
this.showPicker = false
// Auto close after 3 seconds if no errors
if (this.processStatus.failed === 0) {
setTimeout(this.closeProcess, 3000)
}
})
},
closeProcess() {
this.pickerforCircle = null
this.isProcessing = false
this.isProcessDone = false
// Reset
this.processStatus.failed = 0
this.processStatus.progress = 0
this.processStatus.success = 0
this.processStatus.total = 0
},
},
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -1,8 +1,7 @@
<!--
- @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
- @author Charismatic Claire <charismatic.claire@noservice.noreply>
-
- @license GNU AGPL version 3 or any later version
-
@ -22,44 +21,48 @@
-->
<template>
<AppNavigationItem v-for="group in groupsMenu"
: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>
<VirtualList class="member-list app-content-list"
data-key="id"
:data-sources="list"
:data-component="MemberListItem"
:estimate-size="68"
item-class="member-list__item" />
</template>
<script>
import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
import MemberListItem from './MemberList/MemberListItem'
import VirtualList from 'vue-virtual-scroll-list'
export default {
name: 'GroupsNavigation',
name: 'MemberList',
components: {
AppNavigationItem,
VirtualList,
},
props: {
groupsMenu: {
list: {
type: Array,
required: true,
},
},
data() {
return {
MemberListItem,
}
},
computed: {
},
watch: {
},
methods: {
},
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,222 @@
<!--
- @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>
<ListItemIcon
:id="source.id"
:key="source.id"
:avatar-size="44"
:is-no-user="!source.isUser"
:subtitle="levelName"
:title="source.displayName"
:user="source.userId">
<Actions @close="onMenuClose">
<template v-if="loading">
<ActionText icon="icon-loading-small">
{{ t('contacts', 'Loading …') }}
</ActionText>
</template>
<!-- Level picker -->
<template v-else-if="showLevelMenu">
<ActionButton @click="toggleLevelMenu">
{{ t('contacts', 'Back to the menu') }}
<ArrowLeft slot="icon"
:size="16"
decorative />
</ActionButton>
<ActionButton
v-for="level in availableLevelsChange"
:key="level"
icon=""
@click="changeLevel(level)">
{{ CIRCLES_MEMBER_LEVELS[level] }}
</ActionButton>
</template>
<!-- Normal menu -->
<template v-else>
<ActionButton v-if="canChangeLevel" @click="toggleLevelMenu">
{{ t('contacts', 'Change level') }}
<ShieldCheck slot="icon"
:size="16"
decorative />
</ActionButton>
<ActionButton v-if="canDelete" icon="icon-delete" @click="deleteMember">
{{ deleteMemberName }}
</ActionButton>
</template>
</Actions>
</ListItemIcon>
</template>
<script>
import { MEMBER_LEVEL_MEMBER, CIRCLES_MEMBER_LEVELS } from '../../models/constants'
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ListItemIcon from '@nextcloud/vue/dist/Components/ListItemIcon'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import ActionText from '@nextcloud/vue/dist/Components/ActionText'
import ShieldCheck from 'vue-material-design-icons/ShieldCheck'
import ArrowLeft from 'vue-material-design-icons/ArrowLeft'
import RouterMixin from '../../mixins/RouterMixin'
import Member from '../../models/member'
import { showError } from '@nextcloud/dialogs'
export default {
name: 'MemberListItem',
components: {
Actions,
ActionButton,
ActionText,
ArrowLeft,
ListItemIcon,
ShieldCheck,
},
mixins: [RouterMixin],
props: {
source: {
type: Member,
required: true,
},
},
data() {
return {
CIRCLES_MEMBER_LEVELS,
loading: false,
showLevelMenu: false,
}
},
computed: {
circle() {
return this.$store.getters.getCircle(this.selectedCircle)
},
avatarUrl() {
if (this.contact.url) {
return `${this.contact.url}?photo`
}
return undefined
},
/**
* Current member level translated name
* @returns {string}
*/
levelName() {
return CIRCLES_MEMBER_LEVELS[this.source.level]
|| CIRCLES_MEMBER_LEVELS[MEMBER_LEVEL_MEMBER]
},
deleteMemberName() {
return this.currentUserId === this.source.id
? t('contacts', 'Leave this circle')
: t('contacts', 'Remove member')
},
/**
* Current user member level
* @returns {number}
*/
currentUserLevel() {
return this.circle?.initiator?.level || MEMBER_LEVEL_MEMBER
},
/**
* Current user member level
* @returns {string}
*/
currentUserId() {
return this.circle?.initiator?.id
},
/**
* Available levels change to the current user
* @returns {Array}
*/
availableLevelsChange() {
return Object.keys(CIRCLES_MEMBER_LEVELS).filter(level => level < this.currentUserLevel)
},
/**
* Can the current user change the level of others?
* @returns {boolean}
*/
canChangeLevel() {
// we can change if the member is at the same
// or lower level as the current user
return this.availableLevelsChange.length > 0
&& this.currentUserLevel >= this.source.level
&& this.circle.canManageMembers
},
/**
* Can the current user delete members?
* @returns {boolean}
*/
canDelete() {
return this.currentUserLevel > MEMBER_LEVEL_MEMBER
&& this.source.level <= this.currentUserLevel
},
},
methods: {
toggleLevelMenu() {
this.showLevelMenu = !this.showLevelMenu
},
/**
* Delete the current member
*/
async deleteMember() {
this.loading = true
try {
await this.$store.dispatch('deleteMemberFromCircle', this.source)
} catch (error) {
console.error('Could not delete the member', this.source, error)
showError(t('contacts', 'Could not delete the member {displayName}', this.source))
} finally {
this.loading = false
}
},
/**
* Reset menu on close
*/
onMenuClose() {
this.showLevelMenu = false
},
},
}
</script>
<style lang="scss">
.member-list__item {
padding: 8px;
}
</style>

View File

@ -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

View File

@ -0,0 +1,65 @@
/**
* @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/>.
*
*/
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)
}
},
},
}

View File

@ -19,7 +19,17 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
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
},
},
}

307
src/models/circle.js Normal file
View File

@ -0,0 +1,307 @@
/**
* @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/>.
*
*/
/** @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
}
}

75
src/models/constants.js Normal file
View File

@ -0,0 +1,75 @@
/**
* @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/>.
*
*/
/* 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'),
}

View File

@ -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 */
import { MEMBER_TYPE_USER } from './constants'
import Circle from './circle'
import Vue from 'vue'
export default class Member {
/** @typedef Circle */
_circle
_data = {}
/**
* 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
* @param {Object} data the vcard data as string with proper new lines
* @param {Circle} circle the addressbook which the contat belongs to
* @memberof Member
*/
constructor(vcard, addressbook) {
if (typeof vcard !== 'string' || vcard.length === 0) {
throw new Error('Invalid vCard')
constructor(data, circle) {
if (typeof data !== 'object') {
throw new Error('Invalid member')
}
let jCal = ICAL.parse(vcard)
if (jCal[0] !== 'vcard') {
throw new Error('Only one contact is allowed in the vcard data')
// 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')
}
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)
this._circle = circle
this._data = data
}
/**
* Get the circle of this member
* @readonly
* @memberof Member
*/
get circle() {
return this._circle
}
/**
* Set the circle of this member
* @param {Circle} circle the circle
* @memberof Member
*/
set circle(circle) {
if (circle.constructor.name !== Circle.name) {
throw new Error('circle must be a Circle type')
}
this._circle = circle
}
/**
* Update linked addressbook of this contact
*
* @param {Object} addressbook the addressbook
* @memberof Contact
*/
updateAddressbook(addressbook) {
this.addressbook = addressbook
}
/**
* Return the url
*
* Member id
* @readonly
* @memberof Contact
* @memberof Member
*/
get url() {
if (this.dav) {
return this.dav.url
}
return ''
get id() {
return this._data.id
}
/**
* Return the version
*
* Formatted display name
* @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
}
/**
* 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
}
}
/**
* 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 []
}
/**
* Set the groups
*
* @param {Array} groups the groups to set
* @memberof Contact
*/
set groups(groups) {
// delete the title if empty
if (isEmpty(groups)) {
this.vCard.removeProperty('categories')
return
}
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')
}
}
/**
* 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'))
}
/**
* Return the first org
*
* @readonly
* @memberof Contact
*/
get org() {
return this.firstIfArray(this.vCard.getFirstPropertyValue('org'))
}
/**
* Set the org
*
* @param {string} org the org data
* @memberof Contact
*/
set org(org) {
// delete the org if empty
if (isEmpty(org)) {
this.vCard.removeProperty('org')
return
}
this.vCard.updatePropertyWithValue('org', org)
}
/**
* Return the first title
*
* @readonly
* @memberof Contact
*/
get title() {
return this.firstIfArray(this.vCard.getFirstPropertyValue('title'))
}
/**
* Set the title
*
* @param {string} title the title
* @memberof Contact
*/
set title(title) {
// delete the title if empty
if (isEmpty(title)) {
this.vCard.removeProperty('title')
return
}
this.vCard.updatePropertyWithValue('title', title)
}
/**
* Return the full 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
}
/**
* 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
*/
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])
}
get isOrphan() {
return this._circle?.constructor?.name !== 'Circle'
}
/**
* Delete this member and any reference from its circle
*/
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
}
}

View File

@ -55,6 +55,11 @@ export default new Router({
name: 'group',
component: Contacts,
},
{
path: 'circle/:selectedCircle',
name: 'circle',
component: Contacts,
},
{
path: ':selectedGroup/:selectedContact',
name: 'contact',

View File

@ -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)
}

View File

@ -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.<string>} 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 }

48
src/utils/fileUtils.js Normal file
View File

@ -0,0 +1,48 @@
/**
* @copyright Copyright (c) 2019 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 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
}

30
src/utils/numberUtils.js Normal file
View File

@ -0,0 +1,30 @@
/**
* @copyright Copyright (c) 2019 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/>.
*
*/
const isNumber = function(num) {
if (!num) {
return false
}
return Number(num).toString() === num.toString()
}
export { isNumber }

View File

@ -32,12 +32,11 @@
<!-- new-contact-button + navigation + settings -->
<RootNavigation
:contacts-list="contactsList"
:loading="loading"
:loading="loadingContacts || loadingCircles"
:selected-group="selectedGroup"
:selected-contact="selectedContact"
@addContactsToGroup="addContactsToGroup">
:selected-contact="selectedContact">
<!-- new-contact-button -->
<AppNavigationNew v-if="!loading"
<AppNavigationNew v-if="!loadingContacts"
button-id="new-contact-button"
:text="t('contacts','New contact')"
button-class="icon-add"
@ -45,50 +44,13 @@
@click="newContact" />
</RootNavigation>
<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
v-if="!loading"
:list="contactsList"
:contacts="contacts"
:search-query="searchQuery" />
<!-- main contacts details -->
<ContactDetails :contact-key="selectedContact" />
</div>
</AppContent>
<!-- Main content: circle or contacts -->
<CircleContent v-if="selectedCircle"
:loading="loadingCircles" />
<ContactsContent v-else
:contacts-list="contactsList"
:loading="loadingContacts"
@newContact="newContact" />
<!-- Import modal -->
<Modal v-if="isImporting"
@ -98,42 +60,25 @@
<ImportView @close="closeImport" />
</Modal>
<!-- Bulk contacts edit modal -->
<Modal v-if="isProcessing || isProcessDone"
:clear-view-delay="-1"
:can-close="isProcessDone"
@close="closeProcess">
<AddToGroupView v-bind="processStatus" @close="closeProcess" />
</Modal>
<!-- Select contacts group modal -->
<EntityPicker v-else-if="showContactPicker"
:confirm-label="t('contacts', 'Add to group {group}', { group: contactPickerforGroup.name})"
:data-types="pickerTypes"
:data-set="pickerData"
@close="onContactPickerClose"
@submit="onContactPickerPick" />
<ContactsPicker />
</Content>
</template>
<script>
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS } from '../models/groups'
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS } from '../models/constants'
import AppContent from '@nextcloud/vue/dist/Components/AppContent'
import AppNavigationNew from '@nextcloud/vue/dist/Components/AppNavigationNew'
import Content from '@nextcloud/vue/dist/Components/Content'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import isMobile from '@nextcloud/vue/dist/Mixins/isMobile'
import Modal from '@nextcloud/vue/dist/Components/Modal'
import { showError } from '@nextcloud/dialogs'
import { VCardTime } from 'ical.js'
import pLimit from 'p-limit'
import AddToGroupView from './Processing/AddToGroupView'
import ContactDetails from '../components/ContactDetails'
import ContactsList from '../components/ContactsList'
import EntityPicker from '../components/EntityPicker/EntityPicker'
import CircleContent from '../components/AppContent/CircleContent'
import ContactsContent from '../components/AppContent/ContactsContent'
import ContactsPicker from '../components/EntityPicker/ContactsPicker'
import ImportView from './Processing/ImportView'
import RootNavigation from '../components/AppNavigation/RootNavigation'
@ -141,20 +86,16 @@ import Contact from '../models/contact'
import rfcProps from '../models/rfcProps'
import client from '../services/cdav'
import appendContactToGroup from '../services/appendContactToGroup'
export default {
name: 'Contacts',
components: {
AddToGroupView,
AppContent,
AppNavigationNew,
ContactDetails,
ContactsList,
CircleContent,
ContactsContent,
ContactsPicker,
Content,
EmptyContent,
EntityPicker,
ImportView,
Modal,
RootNavigation,
@ -166,6 +107,10 @@ export default {
// passed by the router
props: {
selectedCircle: {
type: String,
default: undefined,
},
selectedGroup: {
type: String,
default: undefined,
@ -178,28 +123,8 @@ export default {
data() {
return {
loading: true,
// Add to group picker
searchQuery: '',
showContactPicker: false,
contactPickerforGroup: null,
pickerData: [],
pickerTypes: [{
id: 'contact',
label: t('contacts', 'Contacts'),
}],
// Bulk processing
isProcessing: false,
isProcessDone: false,
processStatus: {
failed: 0,
progress: 0,
success: 0,
total: 0,
name: '',
},
loadingCircles: true,
loadingContacts: true,
}
},
@ -211,9 +136,6 @@ export default {
sortedContacts() {
return this.$store.getters.getSortedContacts
},
contacts() {
return this.$store.getters.getContacts
},
groups() {
return this.$store.getters.getGroups
},
@ -244,30 +166,6 @@ export default {
return this.addressbooks.find(addressbook => !addressbook.readOnly && addressbook.enabled)
},
/**
* 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 this a real group and is this empty
* @returns {boolean}
*/
isEmptyRealGroup() {
return this.contactsList.length === 0
&& this.isRealGroup
},
/**
* Is the current group empty
* @returns {boolean}
*/
isEmptyGroup() {
return this.contactsList.length === 0
},
/**
* Contacts list based on the selected group.
* Those filters are pretty fast, so let's only
@ -306,13 +204,11 @@ export default {
},
mounted() {
/**
* Register search
*/
// Register search
this.search = new OCA.Search(this.search, this.resetSearch)
},
beforeMount() {
async beforeMount() {
// get addressbooks then get contacts
client.connect({ enableCardDAV: true }).then(() => {
console.debug('Connected to dav!', client)
@ -339,8 +235,8 @@ export default {
})
// Get circles
this.$store.dispatch('getCircles').then(circles => {
console.debug(`Retrieved ${circles.length} circle(s)`, circles)
this.$store.dispatch('getCircles').then(() => {
this.loadingCircles = false
})
},
@ -416,7 +312,7 @@ export default {
return this.$store.dispatch('getContactsFromAddressBook', { addressbook })
})
).then(results => {
this.loading = false
this.loadingContacts = false
if (!this.isMobile) {
this.selectFirstContactIfNone()
}
@ -469,14 +365,6 @@ export default {
}
},
/* SEARCH */
search(query) {
this.searchQuery = query
},
resetSearch() {
this.searchQuery = ''
},
/**
* Show the list and deselect contact
*/
@ -497,122 +385,11 @@ export default {
closeImport() {
this.$store.dispatch('changeStage', 'default')
},
// Bulk contacts group management handlers
addContactsToGroup(group) {
console.debug('Contacts picker opened for group', group)
// Get the full group if we provided the group name only
if (typeof group === 'string') {
group = this.groups.find(a => a.name === group)
if (!group) {
console.error('Cannot add contact to an undefined group', group)
return
}
}
// Init data set
this.pickerData = this.sortedContacts
.map(({ key }) => {
const contact = this.contacts[key]
return {
id: contact.key,
label: contact.displayName,
type: 'contact',
readOnly: contact.addressbook.readOnly,
groups: contact.groups,
}
})
// No read only contacts
.filter(contact => !contact.readOnly)
// No contacts already present in group
.filter(contact => contact.groups.indexOf(group.name) === -1)
this.showContactPicker = true
this.contactPickerforGroup = group
},
onContactPickerClose() {
this.pickerData = []
this.showContactPicker = false
},
onContactPickerPick(selection) {
console.debug('Adding', selection, 'to group', this.contactPickerforGroup)
const groupName = this.contactPickerforGroup.name
this.isProcessing = true
this.showContactPicker = false
this.processStatus.total = selection.length
this.processStatus.name = this.contactPickerforGroup.name
this.processStatus.progress = 0
this.processStatus.failed = 0
// max simultaneous requests
const limit = pLimit(3)
const requests = []
// create the array of requests to send
selection.map(async entity => {
try {
// Get contact
const contact = this.contacts[entity.id]
// push contact to server and use limit
requests.push(limit(() => appendContactToGroup(contact, groupName)
.then((response) => {
this.$store.dispatch('addContactToGroup', { contact, groupName })
this.processStatus.progress++
this.processStatus.success++
})
.catch((error) => {
this.processStatus.progress++
this.processStatus.error++
console.error(error)
})
))
} catch (e) {
console.error(e)
}
})
Promise.all(requests).then(() => {
this.isProcessDone = true
this.showContactPicker = false
// Auto close after 3 seconds if no errors
if (this.processStatus.failed === 0) {
setTimeout(this.closeProcess, 3000)
}
})
},
closeProcess() {
this.contactPickerforGroup = null
this.isProcessing = false
this.isProcessDone = false
// Reset
this.processStatus.failed = 0
this.processStatus.progress = 0
this.processStatus.success = 0
this.processStatus.total = 0
},
},
}
</script>
<style lang="scss" scoped>
#newgroup a {
color: var(--color-text-maxcontrast);
}
#app-content-wrapper {
display: flex;
}
.app-details-toggle {
position: absolute;
width: 44px;