New member button and virtual list

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2021-03-15 11:48:15 +01:00
parent 21c5e699ff
commit 77cc60e0ed
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
27 changed files with 1242 additions and 374 deletions

View File

@ -1,8 +1,20 @@
module.exports = { module.exports = {
globals: { globals: {
appVersion: true appVersion: true,
},
plugins: ['import'],
extends: ['@nextcloud'],
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
paths: './tsconfig.json',
},
},
}, },
extends: [
'@nextcloud'
]
} }

View File

@ -36,13 +36,14 @@
"@mattkrick/sanitize-svg": "^0.3.1", "@mattkrick/sanitize-svg": "^0.3.1",
"@nextcloud/auth": "^1.3.0", "@nextcloud/auth": "^1.3.0",
"@nextcloud/axios": "^1.6.0", "@nextcloud/axios": "^1.6.0",
"@nextcloud/capabilities": "^1.0.4",
"@nextcloud/dialogs": "^3.1.2", "@nextcloud/dialogs": "^3.1.2",
"@nextcloud/event-bus": "^1.2.0", "@nextcloud/event-bus": "^1.2.0",
"@nextcloud/initial-state": "^1.2.0", "@nextcloud/initial-state": "^1.2.0",
"@nextcloud/l10n": "^1.4.1", "@nextcloud/l10n": "^1.4.1",
"@nextcloud/moment": "^1.1.1", "@nextcloud/moment": "^1.1.1",
"@nextcloud/paths": "^1.1.2", "@nextcloud/paths": "^1.1.2",
"@nextcloud/router": "^1.2.0", "@nextcloud/router": "^2.0.0",
"@nextcloud/vue": "^3.9.0", "@nextcloud/vue": "^3.9.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"b64-to-blob": "^1.2.19", "b64-to-blob": "^1.2.19",

View File

@ -50,7 +50,7 @@
</EmptyContent> </EmptyContent>
<EmptyContent v-else-if="circle.isPendingJoin" icon="icon-loading"> <EmptyContent v-else-if="circle.isPendingJoin" icon="icon-loading">
{{ t('contacts', 'Joining circle') }} {{ t('contacts', 'Your request to join this circle is pending approval') }}
</EmptyContent> </EmptyContent>
<EmptyContent v-else icon="icon-loading"> <EmptyContent v-else icon="icon-loading">
@ -76,7 +76,8 @@ import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import CircleDetails from '../CircleDetails' import CircleDetails from '../CircleDetails'
import MemberList from '../MemberList' import MemberList from '../MemberList'
import RouterMixin from '../../mixins/RouterMixin' import RouterMixin from '../../mixins/RouterMixin'
import { MEMBER_LEVEL_NONE } from '../../models/constants' import { joinCircle } from '../../services/circles.ts'
import { showError } from '@nextcloud/dialogs'
export default { export default {
name: 'CircleContent', name: 'CircleContent',
@ -123,14 +124,6 @@ export default {
isEmptyCircle() { isEmptyCircle() {
return this.members.length === 0 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: { watch: {
@ -150,8 +143,17 @@ export default {
/** /**
* Request to join this circle * Request to join this circle
*/ */
requestJoin() { async requestJoin() {
this.loadingJoin = true this.loadingJoin = true
try {
await joinCircle(this.circle.id)
} catch (error) {
showError(t('contacts', 'Unable to join the circle'))
} finally {
this.loadingJoin = false
}
}, },
}, },
} }

View File

@ -41,7 +41,7 @@
<!-- copy circle link --> <!-- copy circle link -->
<ActionLink <ActionLink
:href="circle.url" :href="circleUrl"
:icon="copyLoading ? 'icon-loading-small' : 'icon-public'" :icon="copyLoading ? 'icon-loading-small' : 'icon-public'"
@click.stop.prevent="copyToClipboard(circleUrl)"> @click.stop.prevent="copyToClipboard(circleUrl)">
{{ copyButtonText }} {{ copyButtonText }}
@ -49,7 +49,7 @@
<!-- leave circle --> <!-- leave circle -->
<ActionButton <ActionButton
v-if="circle.isMember" v-if="circle.canLeave"
@click="leaveCircle"> @click="leaveCircle">
{{ t('contacts', 'Leave circle') }} {{ t('contacts', 'Leave circle') }}
<ExitToApp slot="icon" <ExitToApp slot="icon"
@ -59,7 +59,7 @@
<!-- join circle --> <!-- join circle -->
<ActionButton <ActionButton
v-else-if="circle.canJoin" v-else-if="!circle.isMember && circle.canJoin"
@click="joinCircle"> @click="joinCircle">
{{ joinButtonTitle }} {{ joinButtonTitle }}
<LocationEnter slot="icon" <LocationEnter slot="icon"
@ -93,9 +93,10 @@ import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
import ExitToApp from 'vue-material-design-icons/ExitToApp' import ExitToApp from 'vue-material-design-icons/ExitToApp'
import LocationEnter from 'vue-material-design-icons/LocationEnter' import LocationEnter from 'vue-material-design-icons/LocationEnter'
import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin' import { deleteCircle, joinCircle } from '../../services/circles.ts'
import { deleteCircle, joinCircle } from '../../services/circles'
import { showError } from '@nextcloud/dialogs' import { showError } from '@nextcloud/dialogs'
import Circle from '../../models/circle.ts'
import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin'
export default { export default {
name: 'CircleNavigationItem', name: 'CircleNavigationItem',
@ -114,7 +115,7 @@ export default {
props: { props: {
circle: { circle: {
type: Object, type: Circle,
required: true, required: true,
}, },
}, },
@ -136,7 +137,8 @@ export default {
}, },
circleUrl() { circleUrl() {
return window.location.origin + this.circle.url const route = this.$router.resolve(this.circle.router)
return window.location.origin + route.href
}, },
joinButtonTitle() { joinButtonTitle() {
@ -147,21 +149,25 @@ export default {
}, },
memberCount() { memberCount() {
return this.circle?.members?.length || 0 return Object.values(this.circle?.members || []).length
}, },
}, },
methods: { methods: {
// Trigger the entity picker view // Trigger the entity picker view
addMemberToCircle() { async addMemberToCircle() {
await this.$router.push(this.circle.router)
emit('contacts:circles:append', this.circle.id) emit('contacts:circles:append', this.circle.id)
}, },
async joinCircle() { async joinCircle() {
this.loading = true
try { try {
await joinCircle(this.circle.id) await joinCircle(this.circle.id)
} catch (error) { } catch (error) {
showError(t('contacts', 'Unable to join the circle')) showError(t('contacts', 'Unable to join the circle'))
} finally {
this.loading = false
} }
}, },

View File

@ -144,7 +144,7 @@
</template> </template>
<script> <script>
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED, ELLIPSIS_COUNT } from '../../models/constants' import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED, ELLIPSIS_COUNT } from '../../models/constants.ts'
import ActionInput from '@nextcloud/vue/dist/Components/ActionInput' import ActionInput from '@nextcloud/vue/dist/Components/ActionInput'
import ActionText from '@nextcloud/vue/dist/Components/ActionText' import ActionText from '@nextcloud/vue/dist/Components/ActionText'
@ -348,14 +348,14 @@ export default {
this.createCircleError = null this.createCircleError = null
const circleId = await this.$store.dispatch('createCircle', circleName) const circle = await this.$store.dispatch('createCircle', circleName)
this.isNewCircleMenuOpen = false this.isNewCircleMenuOpen = false
// Select group // Select group
this.$router.push({ this.$router.push({
name: 'circle', name: 'circle',
params: { params: {
selectedCircle: circleId, selectedCircle: circle.id,
}, },
}) })
}, },
@ -368,12 +368,12 @@ export default {
#newcircle { #newcircle {
margin-top: 22px; margin-top: 22px;
/deep/ a { ::v-deep a {
color: var(--color-text-maxcontrast) color: var(--color-text-maxcontrast)
} }
} }
.app-navigation__collapse /deep/ a { .app-navigation__collapse ::v-deep a {
color: var(--color-text-maxcontrast) color: var(--color-text-maxcontrast)
} }
</style> </style>

View File

@ -90,7 +90,7 @@ import { encodePath } from '@nextcloud/paths'
import { getCurrentUser } from '@nextcloud/auth' import { getCurrentUser } from '@nextcloud/auth'
import { generateRemoteUrl } from '@nextcloud/router' import { generateRemoteUrl } from '@nextcloud/router'
import { getFilePickerBuilder } from '@nextcloud/dialogs' import { getFilePickerBuilder } from '@nextcloud/dialogs'
import axios from 'axios' import axios from '@nextcloud/axios'
const CancelToken = axios.CancelToken const CancelToken = axios.CancelToken

View File

@ -9,7 +9,7 @@
<!-- contacts picker --> <!-- contacts picker -->
<EntityPicker v-else-if="showPicker" <EntityPicker v-else-if="showPicker"
:confirm-label="t('contacts', 'Add to group {group}', { group: pickerforGroup.name})" :confirm-label="t('contacts', 'Add to {group}', { group: pickerforGroup.name})"
:data-types="pickerTypes" :data-types="pickerTypes"
:data-set="pickerData" :data-set="pickerData"
@close="onContactPickerClose" @close="onContactPickerClose"

View File

@ -109,4 +109,5 @@ export default {
} }
} }
} }
</style> </style>

View File

@ -35,17 +35,17 @@
:placeholder="t('contacts', 'Search {types}', {types: searchPlaceholderTypes})" :placeholder="t('contacts', 'Search {types}', {types: searchPlaceholderTypes})"
class="entity-picker__search-input" class="entity-picker__search-input"
type="search" type="search"
@change="onSearch"> @input="onSearch">
</div> </div>
<!-- Picked entities --> <!-- Picked entities -->
<transition-group <transition-group
v-if="Object.keys(selection).length > 0" v-if="Object.keys(selectionSet).length > 0"
name="zoom" name="zoom"
tag="ul" tag="ul"
class="entity-picker__selection"> class="entity-picker__selection">
<EntityBubble <EntityBubble
v-for="entity in selection" v-for="entity in selectionSet"
:key="entity.key || `entity-${entity.type}-${entity.id}`" :key="entity.key || `entity-${entity.type}-${entity.id}`"
v-bind="entity" v-bind="entity"
@delete="onDelete(entity)" /> @delete="onDelete(entity)" />
@ -68,7 +68,7 @@
:data-sources="availableEntities" :data-sources="availableEntities"
:data-component="EntitySearchResult" :data-component="EntitySearchResult"
:estimate-size="44" :estimate-size="44"
:extra-props="{selection, onClick: onPick}" /> :extra-props="{selection: selectionSet, onClick: onPick}" />
<EmptyContent v-else-if="searchQuery" icon="icon-search"> <EmptyContent v-else-if="searchQuery" icon="icon-search">
{{ t('contacts', 'No results') }} {{ t('contacts', 'No results') }}
@ -92,9 +92,10 @@
</template> </template>
<script> <script>
import Modal from '@nextcloud/vue/dist/Components/Modal' import debounce from 'debounce'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import VirtualList from 'vue-virtual-scroll-list' import VirtualList from 'vue-virtual-scroll-list'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import Modal from '@nextcloud/vue/dist/Components/Modal'
import EntityBubble from './EntityBubble' import EntityBubble from './EntityBubble'
import EntitySearchResult from './EntitySearchResult' import EntitySearchResult from './EntitySearchResult'
@ -138,6 +139,14 @@ export default {
dataSet: { dataSet: {
type: Array, type: Array,
required: true, required: true,
validator: data => {
data.forEach(source => {
if (!source.id || !source.label) {
console.error('The following source MUST have a proper id and label key', source)
}
})
return true
},
}, },
/** /**
@ -155,17 +164,44 @@ export default {
type: String, type: String,
default: t('contacts', 'Add to group'), default: t('contacts', 'Add to group'),
}, },
/**
* Override the local management of selection
* You MUST use a sync modifier or the selection will be locked
*/
selection: {
type: Object,
default: null,
},
}, },
data() { data() {
return { return {
searchQuery: '', searchQuery: '',
selection: {}, localSelection: {},
EntitySearchResult, EntitySearchResult,
} }
}, },
computed: { computed: {
/**
* If the selection is set externally, let's use it
*/
selectionSet: {
get() {
if (this.selection !== null) {
return this.selection
}
return this.localSelection
},
set(selection) {
if (this.selection !== null) {
this.$emit('update:selection', selection)
}
this.localSelection = selection
},
},
/** /**
* Are we handling a single entity type ? * Are we handling a single entity type ?
* @returns {boolean} * @returns {boolean}
@ -179,7 +215,7 @@ export default {
* @returns {boolean} * @returns {boolean}
*/ */
isEmptySelection() { isEmptySelection() {
return Object.keys(this.selection).length === 0 return Object.keys(this.selectionSet).length === 0
}, },
/** /**
@ -219,14 +255,24 @@ export default {
} }
// Else group by types // Else group by types
return this.dataTypes.map(type => [ return this.dataTypes.map(type => {
{ const dataSet = this.searchSet.filter(entity => entity.type === type.id)
id: type.id, const dataList = [
label: type.label, {
heading: true, id: type.id,
}, label: type.label,
...this.searchSet.filter(entity => entity.type === type.id), heading: true,
]).flat() },
...dataSet,
]
// If no results, hide the type
if (dataSet.length === 0) {
return []
}
return dataList
}).flat()
}, },
}, },
@ -249,23 +295,23 @@ export default {
* Emitted when user submit the form * Emitted when user submit the form
* @type {Array} the selected entities * @type {Array} the selected entities
*/ */
this.$emit('submit', Object.values(this.selection)) this.$emit('submit', Object.values(this.selectionSet))
}, },
onSearch(event) { onSearch: debounce(function() {
/** /**
* Emitted when search change * Emitted when search change
* @type {string} the search query * @type {string} the search query
*/ */
this.$emit('search', this.searchQuery) this.$emit('search', this.searchQuery)
}, }, 200),
/** /**
* Remove entity from selection * Remove entity from selection
* @param {Object} entity the entity to remove * @param {Object} entity the entity to remove
*/ */
onDelete(entity) { onDelete(entity) {
this.$delete(this.selection, entity.id, entity) this.$delete(this.selectionSet, entity.id, entity)
console.debug('Removing entity from selection', entity) console.debug('Removing entity from selection', entity)
}, },
@ -274,7 +320,7 @@ export default {
* @param {Object} entity the entity to add * @param {Object} entity the entity to add
*/ */
onPick(entity) { onPick(entity) {
this.$set(this.selection, entity.id, entity) this.$set(this.selectionSet, entity.id, entity)
console.debug('Added entity to selection', entity) console.debug('Added entity to selection', entity)
}, },
@ -283,7 +329,7 @@ export default {
* @param {Object} entity the entity to add/remove * @param {Object} entity the entity to add/remove
*/ */
onToggle(entity) { onToggle(entity) {
if (entity.id in this.selection) { if (entity.id in this.selectionSet) {
this.onDelete(entity) this.onDelete(entity)
} else { } else {
this.onPick(entity) this.onPick(entity)

View File

@ -29,6 +29,7 @@
class="entity-picker__bubble" class="entity-picker__bubble"
:class="{'entity-picker__bubble--selected': isSelected}" :class="{'entity-picker__bubble--selected': isSelected}"
:display-name="source.label" :display-name="source.label"
:user="source.user"
:margin="6" :margin="6"
:size="44" :size="44"
url="#" url="#"
@ -145,6 +146,11 @@ $icon-margin: ($clickable-area - $icon-size) / 2;
&, * { &, * {
// the whole row is clickable,let's force the proper cursor // the whole row is clickable,let's force the proper cursor
cursor: pointer; cursor: pointer;
user-select: none;
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
} }
} }

View File

@ -21,24 +21,58 @@
--> -->
<template> <template>
<VirtualList class="member-list app-content-list" <AppContentList>
data-key="id" <div class="members-list__new">
:data-sources="list" <button class="icon-add" @click="onShowPicker(circle.id)">
:data-component="MemberListItem" {{ t('contacts', 'Add members') }}
:estimate-size="68" </button>
item-class="member-list__item" /> </div>
<VirtualList class="members-list"
data-key="id"
:data-sources="list"
:data-component="MembersListItem"
:estimate-size="68" />
<!-- member picker -->
<EntityPicker v-if="showPicker"
:confirm-label="t('contacts', 'Add to {circle}', { circle: circle.displayName})"
:data-types="pickerTypes"
:data-set="pickerData"
:loading="pickerLoading"
:selection.sync="pickerSelection"
@close="resetPicker"
@search="onSearch"
@submit="onPickerPick" />
</AppContentList>
</template> </template>
<script> <script>
import MemberListItem from './MemberList/MemberListItem' import AppContentList from '@nextcloud/vue/dist/Components/AppContentList'
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import VirtualList from 'vue-virtual-scroll-list' import VirtualList from 'vue-virtual-scroll-list'
import MembersListItem from './MembersList/MembersListItem'
import EntityPicker from './EntityPicker/EntityPicker'
import RouterMixin from '../mixins/RouterMixin'
import { getRecommendations, getSuggestions } from '../services/collaborationAutocompletion'
import { showError, showWarning } from '@nextcloud/dialogs'
import { subscribe } from '@nextcloud/event-bus'
import { SHARES_TYPES_MEMBER_MAP } from '../models/constants.ts'
export default { export default {
name: 'MemberList', name: 'MemberList',
components: { components: {
Actions,
ActionButton,
AppContentList,
VirtualList, VirtualList,
EntityPicker,
}, },
mixins: [RouterMixin],
props: { props: {
list: { list: {
@ -49,20 +83,165 @@ export default {
data() { data() {
return { return {
MemberListItem, MembersListItem,
pickerLoading: false,
showPicker: false,
recommendations: [],
pickerCircle: null,
pickerData: [],
pickerSelection: {},
pickerTypes: [{
id: `picker-${OC.Share.SHARE_TYPE_USER}`,
label: t('contacts', 'Users'),
}, {
id: `picker-${OC.Share.SHARE_TYPE_GROUP}`,
label: t('contacts', 'Groups'),
}, {
id: `picker-${OC.Share.SHARE_TYPE_CIRCLE}`,
label: t('contacts', 'Circles'),
}, {
id: `picker-${OC.Share.SHARE_TYPE_EMAIL}`,
label: t('contacts', 'Email'),
}],
} }
}, },
computed: { computed: {
/**
* Return the current circle
* @returns {Circle}
*/
circle() {
return this.$store.getters.getCircle(this.selectedCircle)
},
}, },
watch: { mounted() {
subscribe('contacts:circles:append', this.onShowPicker)
}, },
methods: { methods: {
/**
* Show picker and fetch for recommendations
* Cache the circleId in case the url change or something
* and make sure we add them to the desired circle.
* @param {string} circleId the circle id to add members to
*/
async onShowPicker(circleId) {
this.showPicker = true
this.pickerLoading = true
this.pickerCircle = circleId
try {
const results = await getRecommendations()
// cache recommendations
this.recommendations = results
this.pickerData = results
} catch (error) {
console.error('Unable to get the recommendations list', error)
// Do not show the error, let the user search
// showError(t('contacts', 'Unable to get the recommendations list'))
} finally {
this.pickerLoading = false
}
},
/**
* On EntityPicker search.
* Returns recommendations if empty
* @param {string} term the searched term
*/
async onSearch(term) {
if (term.trim() === '') {
this.pickerData = this.recommendations
return
}
this.pickerLoading = true
try {
const results = await getSuggestions(term)
this.pickerData = results
} catch (error) {
console.error('Unable to get the results', error)
showError(t('contacts', 'Unable to get the results'))
} finally {
this.pickerLoading = false
}
},
/**
* On picker submit
* @param {Array} selection the selection to add to the circle
*/
async onPickerPick(selection) {
console.info('Adding selection to circle', selection, this.pickerCircle)
this.pickerLoading = true
selection = selection.map(entry => ({
id: entry.shareWith,
type: SHARES_TYPES_MEMBER_MAP[entry.shareType],
}))
try {
const members = await this.$store.dispatch('addMembersToCircle', { circleId: this.pickerCircle, selection })
if (members.length !== selection.length) {
showWarning(t('contacts', 'Some members could not be added'))
// TODO filter successful members and edit selection
this.selection = []
return
}
this.resetPicker()
} catch (error) {
showError(t('contacts', 'There was an issue adding members to the circle'))
console.error('There was an issue adding members to the circle', this.pickerCircle, error)
} finally {
this.pickerLoading = false
}
},
/**
* Reset picker related variables
*/
resetPicker() {
this.showPicker = false
this.pickerCircle = null
this.pickerData = []
},
}, },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.app-content-list {
flex: 1 1 300px;
// Cancel scrolling
overflow: visible;
.empty-content {
padding: 20px;
}
}
.members-list {
// Make virtual scroller scrollable
max-height: 100%;
overflow: auto;
&__new {
padding: 10px;
button {
height: 44px;
padding-left: 44px;
background-position: 14px center;
text-align: left;
width: 100%;
}
}
}
</style> </style>

View File

@ -22,13 +22,14 @@
<template> <template>
<ListItemIcon <ListItemIcon
:id="source.id" :id="source.singleId"
:key="source.id" :key="source.singleId"
:avatar-size="44" :avatar-size="44"
:is-no-user="!source.isUser" :is-no-user="!source.isUser"
:subtitle="levelName" :subtitle="levelName"
:title="source.displayName" :title="source.displayName"
:user="source.userId"> :user="source.userId"
class="members-list__item">
<Actions @close="onMenuClose"> <Actions @close="onMenuClose">
<template v-if="loading"> <template v-if="loading">
<ActionText icon="icon-loading-small"> <ActionText icon="icon-loading-small">
@ -36,31 +37,26 @@
</ActionText> </ActionText>
</template> </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 --> <!-- Normal menu -->
<template v-else> <template v-else>
<ActionButton v-if="canChangeLevel" @click="toggleLevelMenu"> <!-- Level picker -->
{{ t('contacts', 'Change level') }} <template v-if="canChangeLevel">
<ShieldCheck slot="icon" <ActionText>
:size="16" {{ t('contacts', 'Manage level') }}
decorative /> <ShieldCheck slot="icon"
</ActionButton> :size="16"
decorative />
</ActionText>
<ActionButton
v-for="level in availableLevelsChange"
:key="level"
icon=""
@click="changeLevel(level)">
{{ levelChangeLabel(level) }}
</ActionButton>
<ActionSeparator />
</template>
<!-- Leave or delete member from circle --> <!-- Leave or delete member from circle -->
<ActionButton v-if="isCurrentUser && !circle.isOwner" @click="deleteMember"> <ActionButton v-if="isCurrentUser && !circle.isOwner" @click="deleteMember">
@ -78,30 +74,30 @@
</template> </template>
<script> <script>
import { MEMBER_LEVEL_MEMBER, CIRCLES_MEMBER_LEVELS } from '../../models/constants' import { CIRCLES_MEMBER_LEVELS, MemberLevels } from '../../models/constants.ts'
import Actions from '@nextcloud/vue/dist/Components/Actions' import Actions from '@nextcloud/vue/dist/Components/Actions'
import ListItemIcon from '@nextcloud/vue/dist/Components/ListItemIcon' import ListItemIcon from '@nextcloud/vue/dist/Components/ListItemIcon'
import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import ActionText from '@nextcloud/vue/dist/Components/ActionText' import ActionText from '@nextcloud/vue/dist/Components/ActionText'
import ArrowLeft from 'vue-material-design-icons/ArrowLeft'
import ExitToApp from 'vue-material-design-icons/ExitToApp' import ExitToApp from 'vue-material-design-icons/ExitToApp'
import ShieldCheck from 'vue-material-design-icons/ShieldCheck' import ShieldCheck from 'vue-material-design-icons/ShieldCheck'
import { changeMemberLevel } from '../../services/circles' import { changeMemberLevel } from '../../services/circles.ts'
import { showError } from '@nextcloud/dialogs' import { showError } from '@nextcloud/dialogs'
import Member from '../../models/member' import Member from '../../models/member.ts'
import RouterMixin from '../../mixins/RouterMixin' import RouterMixin from '../../mixins/RouterMixin'
export default { export default {
name: 'MemberListItem', name: 'MembersListItem',
components: { components: {
Actions, Actions,
ActionButton, ActionButton,
ActionSeparator,
ActionText, ActionText,
ArrowLeft,
ExitToApp, ExitToApp,
ListItemIcon, ListItemIcon,
ShieldCheck, ShieldCheck,
@ -120,7 +116,6 @@ export default {
CIRCLES_MEMBER_LEVELS, CIRCLES_MEMBER_LEVELS,
loading: false, loading: false,
showLevelMenu: false,
} }
}, },
@ -146,7 +141,7 @@ export default {
*/ */
levelName() { levelName() {
return CIRCLES_MEMBER_LEVELS[this.source.level] return CIRCLES_MEMBER_LEVELS[this.source.level]
|| CIRCLES_MEMBER_LEVELS[MEMBER_LEVEL_MEMBER] || CIRCLES_MEMBER_LEVELS[MemberLevels.MEMBER]
}, },
/** /**
@ -154,7 +149,7 @@ export default {
* @returns {number} * @returns {number}
*/ */
currentUserLevel() { currentUserLevel() {
return this.circle?.initiator?.level || MEMBER_LEVEL_MEMBER return this.circle?.initiator?.level || MemberLevels.MEMBER
}, },
/** /**
@ -162,7 +157,7 @@ export default {
* @returns {string} * @returns {string}
*/ */
currentUserId() { currentUserId() {
return this.circle?.initiator?.id return this.circle?.initiator?.singleId
}, },
/** /**
@ -170,7 +165,13 @@ export default {
* @returns {Array} * @returns {Array}
*/ */
availableLevelsChange() { availableLevelsChange() {
return Object.keys(CIRCLES_MEMBER_LEVELS).filter(level => level < this.currentUserLevel) return Object.keys(CIRCLES_MEMBER_LEVELS)
// Object.keys returns those as string
.map(level => parseInt(level, 10))
// we cannot set to a level higher than the current user's level
.filter(level => level < this.currentUserLevel)
// we cannot set to the level this member is already
.filter(level => level !== this.source.level)
}, },
/** /**
@ -178,7 +179,7 @@ export default {
* @returns {boolean} * @returns {boolean}
*/ */
isCurrentUser() { isCurrentUser() {
return this.currentUserId === this.source.id return this.currentUserId === this.source.singleId
}, },
/** /**
@ -188,9 +189,11 @@ export default {
canChangeLevel() { canChangeLevel() {
// we can change if the member is at the same // we can change if the member is at the same
// or lower level as the current user // or lower level as the current user
// BUT not an owner as there can/must always be one
return this.availableLevelsChange.length > 0 return this.availableLevelsChange.length > 0
&& this.currentUserLevel >= this.source.level && this.currentUserLevel >= this.source.level
&& this.circle.canManageMembers && this.circle.canManageMembers
&& !(this.circle.isOwner && this.isCurrentUser)
}, },
/** /**
@ -198,13 +201,22 @@ export default {
* @returns {boolean} * @returns {boolean}
*/ */
canDelete() { canDelete() {
return this.currentUserLevel > MEMBER_LEVEL_MEMBER return this.currentUserLevel > MemberLevels.MEMBER
&& this.source.level <= this.currentUserLevel && this.source.level <= this.currentUserLevel
&& !this.isCurrentUser
}, },
}, },
methods: { methods: {
toggleLevelMenu() { /**
this.showLevelMenu = !this.showLevelMenu * Return the promote/demote member action label
* @param {MemberLevel} level the member level
* @returns {string}
*/
levelChangeLabel(level) {
if (this.source.level < level) {
return t('contacts', 'Promote to {level}', { level: CIRCLES_MEMBER_LEVELS[level] })
}
return t('contacts', 'Demote to {level}', { level: CIRCLES_MEMBER_LEVELS[level] })
}, },
/** /**
@ -219,6 +231,10 @@ export default {
leave: this.isCurrentUser, leave: this.isCurrentUser,
}) })
} catch (error) { } catch (error) {
if (error.response.status === 404) {
console.debug('Member is not in circle')
return
}
console.error('Could not delete the member', this.source, error) console.error('Could not delete the member', this.source, error)
showError(t('contacts', 'Could not delete the member {displayName}', this.source)) showError(t('contacts', 'Could not delete the member {displayName}', this.source))
} finally { } finally {
@ -232,6 +248,10 @@ export default {
try { try {
await changeMemberLevel(this.circle.id, this.source.id, level) await changeMemberLevel(this.circle.id, this.source.id, level)
this.showLevelMenu = false this.showLevelMenu = false
// this.source is a class. We're modifying the class setter, not the prop itself
// eslint-disable-next-line vue/no-mutating-props
this.source.level = level
} catch (error) { } catch (error) {
console.error('Could not change the member level to', CIRCLES_MEMBER_LEVELS[level]) console.error('Could not change the member level to', CIRCLES_MEMBER_LEVELS[level])
showError(t('contacts', 'Could not change the member level to {level}', { showError(t('contacts', 'Could not change the member level to {level}', {
@ -252,7 +272,12 @@ export default {
} }
</script> </script>
<style lang="scss"> <style lang="scss">
.member-list__item { .members-list__item {
padding: 8px; padding: 8px;
&:focus,
&:hover {
background-color: var(--color-background-hover);
}
} }
</style> </style>

142
src/models/circle.d.ts vendored Normal file
View File

@ -0,0 +1,142 @@
/**
* @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 Member from './member';
declare type MemberList = Record<string, Member>;
export default class Circle {
_data: any;
_members: MemberList;
_owner: Member;
_initiator: Member;
/**
* Creates an instance of Circle
*/
constructor(data: Object);
/**
* Update inner circle data, owner and initiator
*/
updateData(data: any): void;
/**
* Circle id
*/
get id(): string;
/**
* Formatted display name
*/
get displayName(): string;
/**
* Circle creation date
*/
get creation(): number;
/**
* Circle description
*/
get description(): string;
/**
* Circle description
*/
set description(text: string);
/**
* Circle ini_initiator the current
* user info for this circle
*/
get initiator(): Member;
/**
* Circle ownership
*/
get owner(): Member;
/**
* Set new circle owner
*/
set owner(owner: Member);
/**
* Circle members
*/
get members(): MemberList;
/**
* Define members circle
*/
set members(members: MemberList);
/**
* Add a member to this circle
*/
addMember(member: Member): void;
/**
* Remove a member from this circle
*/
deleteMember(member: Member): void;
get settings(): any;
/**
* Circle config
*/
get config(): any;
/**
* Circle requires invite to be confirmed by moderator or above
*/
get requireJoinAccept(): boolean;
/**
* Circle can be requested to join
*/
get canJoin(): boolean;
/**
* Circle is visible to others
*/
get isVisible(): boolean;
/**
* Circle requires invite to be accepted by the member
*/
get requireInviteAccept(): boolean;
/**
* Can the initiator add members to this circle?
*/
get isOwner(): boolean;
/**
* Is the initiator a member of this circle?
*/
get isMember(): boolean;
/**
* Can the initiator delete this circle?
*/
get canDelete(): boolean;
/**
* Can the initiator leave this circle?
*/
get canLeave(): boolean;
/**
* Can the initiator add/remove members to this circle?
*/
get canManageMembers(): boolean;
/**
* Vue router param
*/
get router(): {
name: string;
params: {
selectedCircle: string;
};
};
/**
* Default javascript fallback
* Used for sorting as well
*/
toString(): string;
}
export {};

View File

@ -20,38 +20,31 @@
* *
*/ */
/** @typedef { import('./member') } Member */
import {
MEMBER_LEVEL_MODERATOR, MEMBER_LEVEL_NONE, MEMBER_LEVEL_OWNER,
CIRCLE_CONFIG_REQUEST, CIRCLE_CONFIG_INVITE, CIRCLE_CONFIG_OPEN, CIRCLE_CONFIG_VISIBLE,
} from './constants'
import Vue from 'vue' import Vue from 'vue'
import Member from './member' import Member from './member'
import { CircleConfigs, MemberLevels } from './constants'
type MemberList = Record<string, Member>
export default class Circle { export default class Circle {
_data = {} _data: any = {}
_members = {} _members: MemberList = {}
_owner: Member
_initiator: Member
/** /**
* Creates an instance of Circle * Creates an instance of Circle
*
* @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) { constructor(data: Object) {
this.updateData(data) this.updateData(data)
} }
/** /**
* Update inner circle data, owner and initiator * Update inner circle data, owner and initiator
* @param {Object} data the vcard data as string with proper new lines
* @memberof Circle
*/ */
updateData(data) { updateData(data: any) {
if (typeof data !== 'object') { if (typeof data !== 'object') {
throw new Error('Invalid circle') throw new Error('Invalid circle')
} }
@ -62,145 +55,119 @@ export default class Circle {
} }
this._data = data this._data = data
this._data.initiator = new Member(data.initiator, this) this._owner = new Member(data.owner, this)
this._data.owner = new Member(data.owner)
if (data.initiator) {
this._initiator = new Member(data.initiator, this)
}
} }
// METADATA ----------------------------------------- // METADATA -----------------------------------------
/** /**
* Circle id * Circle id
* @readonly
* @memberof Circle
* @returns {string}
*/ */
get id() { get id(): string {
return this._data.id return this._data.id
} }
/** /**
* Formatted display name * Formatted display name
* @readonly
* @memberof Circle
* @returns {string}
*/ */
get displayName() { get displayName(): string {
return this._data.displayName return this._data.displayName
} }
/** /**
* Circle creation date * Circle creation date
* @readonly
* @memberof Circle
* @returns {number}
*/ */
get creation() { get creation(): number {
return this._data.creation return this._data.creation
} }
/** /**
* Circle description * Circle description
* @readonly
* @memberof Circle
* @returns {string}
*/ */
get description() { get description(): string {
return this._data.description return this._data.description
} }
/** /**
* Circle description * Circle description
* @param {string} text circle description
* @memberof Circle
*/ */
set description(text) { set description(text: string) {
this._data.description = text this._data.description = text
} }
// MEMBERSHIP ----------------------------------------- // MEMBERSHIP -----------------------------------------
/** /**
* Circle initiator. This is the current * Circle ini_initiator the current
* user info for this circle * user info for this circle
* @readonly
* @memberof Circle
* @returns {Member}
*/ */
get initiator() { get initiator(): Member {
return this._data.initiator return this._initiator
} }
/** /**
* Circle ownership * Circle ownership
* @readonly
* @memberof Circle
* @returns {Member}
*/ */
get owner() { get owner(): Member {
return this._data.owner return this._owner
} }
/** /**
* Set new circle owner * Set new circle owner
* @param {Member} owner circle owner
* @memberof Circle
*/ */
set owner(owner) { set owner(owner: Member) {
if (owner.constructor.name !== Member.name) { if (owner.constructor.name !== Member.name) {
throw new Error('Owner must be a Member type') throw new Error('Owner must be a Member type')
} }
this._data.owner = owner this._owner = owner
} }
/** /**
* Circle members * Circle members
* @readonly
* @memberof Circle
* @returns {Member[]}
*/ */
get members() { get members(): MemberList {
return this._members return this._members
} }
/** /**
* Define members circle * Define members circle
* @param {Member[]} members the members list
* @memberof Circle
*/ */
set members(members) { set members(members: MemberList) {
this._members = members this._members = members
} }
/** /**
* Add a member to this circle * Add a member to this circle
* @param {Member} member the member to add
*/ */
addMember(member) { addMember(member: Member) {
if (member.constructor.name !== Member.name) { if (member.constructor.name !== Member.name) {
throw new Error('Member must be a Member type') throw new Error('Member must be a Member type')
} }
const uid = member.id const singleId = member.singleId
if (this._members[uid]) { if (this._members[singleId]) {
console.warn('Duplicate member overrided', this._members[uid], member) console.warn('Ignoring duplicate member', member)
} }
Vue.set(this._members, uid, member) Vue.set(this._members, singleId, member)
} }
/** /**
* Remove a member from this circle * Remove a member from this circle
* @param {Member} member the member to delete
*/ */
deleteMember(member) { deleteMember(member: Member) {
if (member.constructor.name !== Member.name) { if (member.constructor.name !== Member.name) {
throw new Error('Member must be a Member type') throw new Error('Member must be a Member type')
} }
const uid = member.id const singleId = member.singleId
if (!this._members[uid]) { if (!this._members[singleId]) {
console.warn('The member was not in this circle. Nothing was done.', member) console.warn('The member was not in this circle. Nothing was done.', member)
} }
// Delete and clear memory // Delete and clear memory
Vue.delete(this._members, uid) Vue.delete(this._members, singleId)
} }
// CONFIGS -------------------------------------------- // CONFIGS --------------------------------------------
@ -210,9 +177,6 @@ export default class Circle {
/** /**
* Circle config * Circle config
* @readonly
* @memberof Circle
* @returns {number}
*/ */
get config() { get config() {
return this._data.config return this._data.config
@ -220,91 +184,71 @@ export default class Circle {
/** /**
* Circle requires invite to be confirmed by moderator or above * Circle requires invite to be confirmed by moderator or above
* @readonly
* @memberof Circle
* @returns {boolean}
*/ */
get requireJoinAccept() { get requireJoinAccept() {
return (this._data.config & CIRCLE_CONFIG_REQUEST) !== 0 return (this._data.config & CircleConfigs.VISIBLE) !== 0
} }
/** /**
* Circle can be requested to join * Circle can be requested to join
* @readonly
* @memberof Circle
* @returns {boolean}
*/ */
get canJoin() { get canJoin() {
return (this._data.config & CIRCLE_CONFIG_OPEN) !== 0 return (this._data.config & CircleConfigs.OPEN) !== 0
} }
/** /**
* Circle is visible to others * Circle is visible to others
* @readonly
* @memberof Circle
* @returns {boolean}
*/ */
get isVisible() { get isVisible() {
return (this._data.config & CIRCLE_CONFIG_VISIBLE) !== 0 return (this._data.config & CircleConfigs.VISIBLE) !== 0
} }
/** /**
* Circle requires invite to be accepted by the member * Circle requires invite to be accepted by the member
* @readonly
* @memberof Circle
* @returns {boolean}
*/ */
get requireInviteAccept() { get requireInviteAccept() {
return (this._data.config & CIRCLE_CONFIG_INVITE) !== 0 return (this._data.config & CircleConfigs.INVITE) !== 0
} }
// PERMISSIONS SHORTCUTS ------------------------------ // PERMISSIONS SHORTCUTS ------------------------------
/** /**
* Can the initiator add members to this circle? * Can the initiator add members to this circle?
* @readonly
* @memberof Circle
* @returns {boolean}
*/ */
get isOwner() { get isOwner() {
return this.initiator.level === MEMBER_LEVEL_OWNER return this.initiator?.level === MemberLevels.OWNER
} }
/** /**
* Is the initiator a member of this circle? * Is the initiator a member of this circle?
* @readonly
* @memberof Circle
* @returns {boolean}
*/ */
get isMember() { get isMember() {
return this.initiator.level > MEMBER_LEVEL_NONE return this.initiator?.level > MemberLevels.NONE
} }
/** /**
* Can the initiator delete this circle? * Can the initiator delete this circle?
* @readonly
* @memberof Circle
* @returns {boolean}
*/ */
get canDelete() { get canDelete() {
return this.isOwner return this.isOwner
} }
/**
* Can the initiator leave this circle?
*/
get canLeave() {
return this.isMember && !this.isOwner
}
/** /**
* Can the initiator add/remove members to this circle? * Can the initiator add/remove members to this circle?
* @readonly
* @memberof Circle
* @returns {boolean}
*/ */
get canManageMembers() { get canManageMembers() {
return this.initiator.level >= MEMBER_LEVEL_MODERATOR return this.initiator?.level >= MemberLevels.MODERATOR
} }
// PARAMS --------------------------------------------- // PARAMS ---------------------------------------------
/** /**
* Vue router param * Vue router param
* @readonly
* @memberof Circle
* @returns {Object}
*/ */
get router() { get router() {
return { return {
@ -316,8 +260,6 @@ export default class Circle {
/** /**
* Default javascript fallback * Default javascript fallback
* Used for sorting as well * Used for sorting as well
* @memberof Circle
* @returns {string}
*/ */
toString() { toString() {
return this.displayName return this.displayName

68
src/models/constants.d.ts vendored Normal file
View File

@ -0,0 +1,68 @@
/**
* @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/>.
*
*/
export declare type CircleConfig = number;
export declare type MemberLevel = number;
export declare type MemberType = number;
export declare const LIST_SIZE = 60;
export declare const GROUP_ALL_CONTACTS: string;
export declare const GROUP_NO_GROUP_CONTACTS: string;
export declare const GROUP_RECENTLY_CONTACTED: string;
export declare const ROUTE_CIRCLE = "circle";
export declare const ELLIPSIS_COUNT = 5;
export declare const CIRCLES_MEMBER_TYPES: {
[x: number]: string;
};
export declare const CIRCLES_MEMBER_LEVELS: {
[x: number]: string;
};
export declare const SHARES_TYPES_MEMBER_MAP: {
[x: number]: number;
};
export declare enum MemberLevels {
NONE,
MEMBER,
MODERATOR,
ADMIN,
OWNER
}
export declare enum MemberTypes {
CIRCLE,
USER,
GROUP,
MAIL,
CONTACT
}
export declare enum CircleConfigs {
SYSTEM,
VISIBLE,
OPEN,
INVITE,
REQUEST,
FRIEND,
PROTECTED,
NO_OWNER,
HIDDEN,
BACKEND,
ROOT,
CIRCLE_INVITE,
FEDERATED
}

View File

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

133
src/models/constants.ts Normal file
View File

@ -0,0 +1,133 @@
/**
* @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/>.
*
*/
/// <reference types="@nextcloud/typings" />
import { translate as t } from '@nextcloud/l10n'
interface OC extends Nextcloud.Common.OC {
Share: any
}
declare const OC: OC
export type CircleConfig = number
export type MemberLevel = number
export type MemberType = number
// Global sizes
export const LIST_SIZE = 60
// 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')
// Circle route, see vue-router conf
export const ROUTE_CIRCLE = 'circle'
// Default max number of items to show in the navigation
export const ELLIPSIS_COUNT = 5
// Circles member levels
const MEMBER_LEVEL_NONE: MemberLevel = 0
const MEMBER_LEVEL_MEMBER: MemberLevel = 1
const MEMBER_LEVEL_MODERATOR: MemberLevel = 4
const MEMBER_LEVEL_ADMIN: MemberLevel = 8
const MEMBER_LEVEL_OWNER: MemberLevel = 9
// Circles member types
const MEMBER_TYPE_SINGLEID: MemberType = 0
const MEMBER_TYPE_USER: MemberType = 1
const MEMBER_TYPE_GROUP : MemberType= 2
const MEMBER_TYPE_MAIL: MemberType = 4
const MEMBER_TYPE_CONTACT: MemberType = 8
const MEMBER_TYPE_CIRCLE: MemberType = 16
// Circles config flags
const CIRCLE_CONFIG_SYSTEM: CircleConfig = 4 // System Circle (not managed by the official front-end). Meaning some config are limited
const CIRCLE_CONFIG_VISIBLE: CircleConfig = 8 // Visible to everyone, if not visible, people have to know its name to be able to find it
const CIRCLE_CONFIG_OPEN: CircleConfig = 16 // Circle is open, people can join
const CIRCLE_CONFIG_INVITE: CircleConfig = 32 // Adding a member generate an invitation that needs to be accepted
const CIRCLE_CONFIG_REQUEST: CircleConfig = 64 // Request to join Circles needs to be confirmed by a moderator
const CIRCLE_CONFIG_FRIEND: CircleConfig = 128 // Members of the circle can invite their friends
const CIRCLE_CONFIG_PROTECTED: CircleConfig = 256 // Password protected to join/request
const CIRCLE_CONFIG_NO_OWNER: CircleConfig = 512 // no owner, only members
const CIRCLE_CONFIG_HIDDEN: CircleConfig = 1024 // hidden from listing, but available as a share entity
const CIRCLE_CONFIG_BACKEND: CircleConfig = 2048 // Fully hidden, only backend Circles
const CIRCLE_CONFIG_ROOT: CircleConfig = 4096 // Circle cannot be inside another Circle
const CIRCLE_CONFIG_CIRCLE_INVITE: CircleConfig = 8192 // Circle must confirm when invited in another circle
const CIRCLE_CONFIG_FEDERATED: CircleConfig = 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'),
}
export const SHARES_TYPES_MEMBER_MAP = {
[OC.Share.SHARE_TYPE_CIRCLE]: MEMBER_TYPE_SINGLEID,
[OC.Share.SHARE_TYPE_USER]: MEMBER_TYPE_USER,
[OC.Share.SHARE_TYPE_GROUP]: MEMBER_TYPE_GROUP,
[OC.Share.SHARE_TYPE_EMAIL]: MEMBER_TYPE_MAIL,
// []: MEMBER_TYPE_CONTACT,
}
export enum MemberLevels {
NONE = MEMBER_LEVEL_NONE,
MEMBER = MEMBER_LEVEL_MEMBER,
MODERATOR = MEMBER_LEVEL_MODERATOR,
ADMIN = MEMBER_LEVEL_ADMIN,
OWNER = MEMBER_LEVEL_OWNER,
}
export enum MemberTypes {
CIRCLE = MEMBER_TYPE_CIRCLE,
USER = MEMBER_TYPE_USER,
GROUP = MEMBER_TYPE_GROUP,
MAIL = MEMBER_TYPE_MAIL,
CONTACT = MEMBER_TYPE_CONTACT,
}
export enum CircleConfigs {
SYSTEM = CIRCLE_CONFIG_SYSTEM,
VISIBLE = CIRCLE_CONFIG_VISIBLE,
OPEN = CIRCLE_CONFIG_OPEN,
INVITE = CIRCLE_CONFIG_INVITE,
REQUEST = CIRCLE_CONFIG_REQUEST,
FRIEND = CIRCLE_CONFIG_FRIEND,
PROTECTED = CIRCLE_CONFIG_PROTECTED,
NO_OWNER = CIRCLE_CONFIG_NO_OWNER,
HIDDEN = CIRCLE_CONFIG_HIDDEN,
BACKEND = CIRCLE_CONFIG_BACKEND,
ROOT = CIRCLE_CONFIG_ROOT,
CIRCLE_INVITE = CIRCLE_CONFIG_CIRCLE_INVITE,
FEDERATED = CIRCLE_CONFIG_FEDERATED,
}

76
src/models/member.d.ts vendored Normal file
View File

@ -0,0 +1,76 @@
/**
* @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 Circle from './circle';
import { MemberLevel } from './constants';
export default class Member {
_data: any;
_circle: Circle;
/**
* Creates an instance of Member
*/
constructor(data: any, circle: Circle);
/**
* Get the circle of this member
*/
get circle(): Circle;
/**
* Set the circle of this member
*/
set circle(circle: Circle);
/**
* Member id
*/
get id(): string;
/**
* Single uid
*/
get singleId(): string;
/**
* Formatted display name
*/
get displayName(): string;
/**
* Member userId
*/
get userId(): string;
/**
* Member level
*
*/
get level(): MemberLevel;
/**
* Set member level
*/
set level(level: MemberLevel);
/**
* Is the current member a user?
*/
get isUser(): boolean;
/**
* Is the current member without a circle?
*/
get isOrphan(): boolean;
/**
* Delete this member and any reference from its circle
*/
delete(): void;
}

View File

@ -20,25 +20,18 @@
* *
*/ */
/** @typedef { import('./circle') } Circle */
import { MEMBER_TYPE_USER } from './constants'
import Circle from './circle' import Circle from './circle'
import { MemberLevel, MemberLevels } from './constants'
export default class Member { export default class Member {
/** @typedef Circle */ _data: any = {}
_circle _circle: Circle
_data = {}
/** /**
* Creates an instance of Contact * Creates an instance of Member
*
* @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(data, circle) { constructor(data: any, circle: Circle) {
if (typeof data !== 'object') { if (typeof data !== 'object') {
throw new Error('Invalid member') throw new Error('Invalid member')
} }
@ -55,19 +48,15 @@ export default class Member {
/** /**
* Get the circle of this member * Get the circle of this member
* @readonly
* @memberof Member
*/ */
get circle() { get circle(): Circle {
return this._circle return this._circle
} }
/** /**
* Set the circle of this member * Set the circle of this member
* @param {Circle} circle the circle
* @memberof Member
*/ */
set circle(circle) { set circle(circle: Circle) {
if (circle.constructor.name !== Circle.name) { if (circle.constructor.name !== Circle.name) {
throw new Error('circle must be a Circle type') throw new Error('circle must be a Circle type')
} }
@ -76,54 +65,59 @@ export default class Member {
/** /**
* Member id * Member id
* @readonly
* @memberof Member
*/ */
get id() { get id(): string {
return this._data.id return this._data.id
} }
/** /**
* Formatted display name * Single uid
* @readonly
* @memberof Member
*/ */
get displayName() { get singleId(): string {
return this._data.singleId
}
/**
* Formatted display name
*/
get displayName(): string {
return this._data.displayName return this._data.displayName
} }
/** /**
* Member userId * Member userId
* @readonly
* @memberof Member
*/ */
get userId() { get userId(): string {
return this._data.userId return this._data.userId
} }
/** /**
* Member level * Member level
* @see file src/models/constants.js *
* @readonly
* @memberof Member
*/ */
get level() { get level(): MemberLevel {
return this._data.level return this._data.level
} }
/**
* Set member level
*/
set level(level: MemberLevel) {
if (!(level in MemberLevels)) {
throw new Error('Invalid level')
}
this._data.level = level
}
/** /**
* Is the current member a user? * Is the current member a user?
* @readonly
* @memberof Member
*/ */
get isUser() { get isUser() {
return this._data.userType === MEMBER_TYPE_USER return this._data.userType === MemberLevels.MEMBER
} }
/** /**
* Is the current member without a circle? * Is the current member without a circle?
* @readonly
* @memberof Member
*/ */
get isOrphan() { get isOrphan() {
return this._circle?.constructor?.name !== 'Circle' return this._circle?.constructor?.name !== 'Circle'
@ -137,7 +131,6 @@ export default class Member {
throw new Error('Cannot delete this member as it doesn\'t belong to any circle') throw new Error('Cannot delete this member as it doesn\'t belong to any circle')
} }
this.circle.deleteMember(this) this.circle.deleteMember(this)
this._circle = undefined
this._data = undefined this._data = undefined
} }

View File

@ -23,6 +23,8 @@
import Vue from 'vue' import Vue from 'vue'
import Router from 'vue-router' import Router from 'vue-router'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { ROUTE_CIRCLE } from '../models/constants.ts'
import Contacts from '../views/Contacts' import Contacts from '../views/Contacts'
Vue.use(Router) Vue.use(Router)
@ -51,13 +53,13 @@ export default new Router({
component: Contacts, component: Contacts,
}, },
{ {
path: ':selectedGroup', path: `${ROUTE_CIRCLE}/:selectedCircle`,
name: 'group', name: 'circle',
component: Contacts, component: Contacts,
}, },
{ {
path: 'circle/:selectedCircle', path: ':selectedGroup',
name: 'circle', name: 'group',
component: Contacts, component: Contacts,
}, },
{ {

101
src/services/circles.d.ts vendored Normal file
View File

@ -0,0 +1,101 @@
/**
* @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { MemberLevel, MemberType } from '../models/constants';
interface MemberPairs {
id: string;
type: MemberType;
}
/**
* Get the circles list without the members
*
* @returns {Array}
*/
export declare const getCircles: () => Promise<any>;
/**
* Create a new circle
*
* @param {string} name the circle name
* @returns {Object}
*/
export declare const createCircle: (name: string) => Promise<any>;
/**
* Delete an existing circle
*
* @param {string} circleId the circle name
* @returns {Object}
*/
export declare const deleteCircle: (circleId: string) => Promise<any>;
/**
* Join a circle
*
* @param {string} circleId the circle name
* @returns {Array}
*/
export declare const joinCircle: (circleId: string) => Promise<any>;
/**
* Leave a circle
*
* @param {string} circleId the circle name
* @returns {Array}
*/
export declare const leaveCircle: (circleId: string) => Promise<any>;
/**
* Get the circle members without the members
*
* @param {string} circleId the circle id
* @returns {Array}
*/
export declare const getCircleMembers: (circleId: string) => Promise<any>;
/**
* Search a potential circle member
*
* @param {string} term the search query
* @returns {Array}
*/
export declare const searchMember: (term: string) => Promise<any>;
/**
* Add a circle member
*
* @param {string} circleId the circle id
* @param {string} members the member id
* @returns {Array}
*/
export declare const addMembers: (circleId: string, members: Array<MemberPairs>) => Promise<any>;
/**
* Delete a circle member
*
* @param {string} circleId the circle id
* @param {string} memberId the member id
* @returns {Array}
*/
export declare const deleteMember: (circleId: string, memberId: string) => Promise<unknown[]>;
/**
* 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 declare const changeMemberLevel: (circleId: string, memberId: string, level: MemberLevel) => Promise<unknown[]>;
export {};

View File

@ -22,9 +22,12 @@
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router' import { generateOcsUrl } from '@nextcloud/router'
import { CIRCLES_MEMBER_LEVELS } from '../models/constants' import { MemberLevel, MemberLevels, MemberType } from '../models/constants'
const baseApi = generateOcsUrl('apps/circles', 2) interface MemberPairs {
id: string,
type: MemberType
}
/** /**
* Get the circles list without the members * Get the circles list without the members
@ -32,7 +35,7 @@ const baseApi = generateOcsUrl('apps/circles', 2)
* @returns {Array} * @returns {Array}
*/ */
export const getCircles = async function() { export const getCircles = async function() {
const response = await axios.get(baseApi + 'circles') const response = await axios.get(generateOcsUrl('apps/circles/circles'))
return response.data.ocs.data return response.data.ocs.data
} }
@ -42,8 +45,8 @@ export const getCircles = async function() {
* @param {string} name the circle name * @param {string} name the circle name
* @returns {Object} * @returns {Object}
*/ */
export const createCircle = async function(name) { export const createCircle = async function(name: string) {
const response = await axios.post(baseApi + 'circles', { const response = await axios.post(generateOcsUrl('apps/circles/circles'), {
name, name,
}) })
return response.data.ocs.data return response.data.ocs.data
@ -55,8 +58,8 @@ export const createCircle = async function(name) {
* @param {string} circleId the circle name * @param {string} circleId the circle name
* @returns {Object} * @returns {Object}
*/ */
export const deleteCircle = async function(circleId) { export const deleteCircle = async function(circleId: string) {
const response = await axios.delete(baseApi + `circles/${circleId}`) const response = await axios.delete(generateOcsUrl('apps/circles/circles/{circleId}', { circleId }))
return response.data.ocs.data return response.data.ocs.data
} }
@ -66,8 +69,8 @@ export const deleteCircle = async function(circleId) {
* @param {string} circleId the circle name * @param {string} circleId the circle name
* @returns {Array} * @returns {Array}
*/ */
export const joinCircle = async function(circleId) { export const joinCircle = async function(circleId: string) {
const response = await axios.put(baseApi + `circles/${circleId}/join`) const response = await axios.put(generateOcsUrl('apps/circles/circles/{circleId}/join', { circleId }))
return response.data.ocs.data return response.data.ocs.data
} }
@ -77,8 +80,8 @@ export const joinCircle = async function(circleId) {
* @param {string} circleId the circle name * @param {string} circleId the circle name
* @returns {Array} * @returns {Array}
*/ */
export const leaveCircle = async function(circleId) { export const leaveCircle = async function(circleId: string) {
const response = await axios.put(baseApi + `circles/${circleId}/leave`) const response = await axios.put(generateOcsUrl('apps/circles/circles/{circleId}/leave', { circleId }))
return response.data.ocs.data return response.data.ocs.data
} }
@ -88,21 +91,32 @@ export const leaveCircle = async function(circleId) {
* @param {string} circleId the circle id * @param {string} circleId the circle id
* @returns {Array} * @returns {Array}
*/ */
export const getCircleMembers = async function(circleId) { export const getCircleMembers = async function(circleId: string) {
const response = await axios.get(baseApi + `circles/${circleId}/members`) const response = await axios.get(generateOcsUrl('apps/circles/circles/{circleId}/members', { circleId }))
return Object.values(response.data.ocs.data) return response.data.ocs.data
}
/**
* Search a potential circle member
*
* @param {string} term the search query
* @returns {Array}
*/
export const searchMember = async function(term: string) {
const response = await axios.get(generateOcsUrl('apps/circles/search?term={term}', { term }))
return response.data.ocs.data
} }
/** /**
* Add a circle member * Add a circle member
* *
* @param {string} circleId the circle id * @param {string} circleId the circle id
* @param {string} memberId the member id * @param {string} members the member id
* @returns {Array} * @returns {Array}
*/ */
export const addMember = async function(circleId, memberId) { export const addMembers = async function(circleId: string, members: Array<MemberPairs>) {
const response = await axios.delete(baseApi + `circles/${circleId}/members/${memberId}`) const response = await axios.post(generateOcsUrl('apps/circles/circles/{circleId}/members/multi', { circleId }), { members })
return Object.values(response.data.ocs.data) return response.data.ocs.data
} }
/** /**
@ -112,8 +126,8 @@ export const addMember = async function(circleId, memberId) {
* @param {string} memberId the member id * @param {string} memberId the member id
* @returns {Array} * @returns {Array}
*/ */
export const deleteMember = async function(circleId, memberId) { export const deleteMember = async function(circleId: string, memberId: string) {
const response = await axios.delete(baseApi + `circles/${circleId}/members/${memberId}`) const response = await axios.delete(generateOcsUrl('apps/circles/circles/{circleId}/members/{memberId}', { circleId, memberId }))
return Object.values(response.data.ocs.data) return Object.values(response.data.ocs.data)
} }
@ -126,12 +140,12 @@ export const deleteMember = async function(circleId, memberId) {
* @param {number} level the new member level * @param {number} level the new member level
* @returns {Array} * @returns {Array}
*/ */
export const changeMemberLevel = async function(circleId, memberId, level) { export const changeMemberLevel = async function(circleId: string, memberId: string, level: MemberLevel) {
if (!(level in CIRCLES_MEMBER_LEVELS)) { if (!(level in MemberLevels)) {
throw new Error('Invalid level. Valid levels are', CIRCLES_MEMBER_LEVELS) throw new Error('Invalid level.')
} }
const response = await axios.put(baseApi + `circles/${circleId}/members/${memberId}}/level`, { const response = await axios.put(generateOcsUrl('apps/circles/circles/{circleId}/members/{memberId}/level', { circleId, memberId }), {
level, level,
}) })
return Object.values(response.data.ocs.data) return Object.values(response.data.ocs.data)

View File

@ -0,0 +1,141 @@
/**
* @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 axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
const maxAutocompleteResults = parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 25
export const shareType = [
OC.Share.SHARE_TYPE_USER,
OC.Share.SHARE_TYPE_GROUP,
// OC.Share.SHARE_TYPE_REMOTE,
// OC.Share.SHARE_TYPE_REMOTE_GROUP,
OC.Share.SHARE_TYPE_CIRCLE,
// OC.Share.SHARE_TYPE_ROOM,
// OC.Share.SHARE_TYPE_GUEST,
// OC.Share.SHARE_TYPE_DECK,
OC.Share.SHARE_TYPE_EMAIL,
]
/**
* Get suggestions
*
* @param {string} search the search query
*/
export const getSuggestions = async function(search) {
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), {
params: {
format: 'json',
itemType: 'file',
search,
perPage: maxAutocompleteResults,
shareType,
},
})
const data = request.data.ocs.data
const exact = request.data.ocs.data.exact
data.exact = [] // removing exact from general results
// flatten array of arrays
const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), [])
// remove invalid data and format to user-select layout
const exactSuggestions = rawExactSuggestions
.filter(result => typeof result === 'object')
.map(share => formatResults(share))
// sort by type so we can get user&groups first...
.sort((a, b) => a.shareType - b.shareType)
const suggestions = rawSuggestions
.filter(result => typeof result === 'object')
.map(share => formatResults(share))
// sort by type so we can get user&groups first...
.sort((a, b) => a.shareType - b.shareType)
const allSuggestions = exactSuggestions.concat(suggestions)
// Count occurances of display names in order to provide a distinguishable description if needed
const nameCounts = allSuggestions.reduce((nameCounts, result) => {
if (!result.displayName) {
return nameCounts
}
if (!nameCounts[result.displayName]) {
nameCounts[result.displayName] = 0
}
nameCounts[result.displayName]++
return nameCounts
}, {})
const finalResults = allSuggestions.map(item => {
// Make sure that items with duplicate displayName get the shareWith applied as a description
if (nameCounts[item.displayName] > 1 && !item.desc) {
return { ...item, desc: item.shareWithDisplayNameUnique }
}
return item
})
console.info('suggestions', finalResults)
return finalResults
}
/**
* Get the sharing recommendations
*/
export const getRecommendations = async function() {
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees_recommended'), {
params: {
format: 'json',
itemType: 'file',
shareType,
},
})
// flatten array of arrays
const exact = request.data.ocs.data.exact
const recommendations = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
// remove invalid data and format to user-select layout
const finalResults = recommendations
.map(share => formatResults(share))
console.info('recommendations', finalResults)
return finalResults
}
const formatResults = function(result) {
const type = `picker-${result.value.shareType}`
return {
label: result.label,
id: `${type}-${result.value.shareWith}`,
// If this is a user, set as user for avatar display by UserBubble
user: result.value.shareType === OC.Share.SHARE_TYPE_USER
? result.value.shareWith
: null,
type,
...result.value,
}
}

View File

@ -23,9 +23,9 @@
import { showError } from '@nextcloud/dialogs' import { showError } from '@nextcloud/dialogs'
import Vue from 'vue' import Vue from 'vue'
import { createCircle, deleteCircle, deleteMember, getCircleMembers, getCircles, leaveCircle } from '../services/circles' import { createCircle, deleteCircle, deleteMember, getCircleMembers, getCircles, leaveCircle, addMembers } from '../services/circles.ts'
import Member from '../models/member' import Member from '../models/member.ts'
import Circle from '../models/circle' import Circle from '../models/circle.ts'
const state = { const state = {
/** @type {Object.<string>} Circle */ /** @type {Object.<string>} Circle */
@ -113,10 +113,20 @@ const actions = {
const circles = await getCircles() const circles = await getCircles()
console.debug(`Retrieved ${circles.length} circle(s)`, circles) console.debug(`Retrieved ${circles.length} circle(s)`, circles)
circles.map(circle => new Circle(circle)) let failure = false
.forEach(circle => { circles.forEach(circle => {
context.commit('addCircle', circle) try {
}) const newCircle = new Circle(circle)
context.commit('addCircle', newCircle)
} catch (error) {
failure = true
console.error('This circle failed to be processed', circle, error)
}
})
if (failure) {
showError(t('contacts', 'Some circle(s) errored, check the console for more details'))
}
return circles return circles
}, },
@ -140,12 +150,15 @@ const actions = {
* *
* @param {Object} context the store mutations Current context * @param {Object} context the store mutations Current context
* @param {string} circleName the circle name * @param {string} circleName the circle name
* @returns {Circle} the new circle
*/ */
async createCircle(context, circleName) { async createCircle(context, circleName) {
try { try {
const response = await createCircle(circleName) const response = await createCircle(circleName)
const circle = new Circle(response) const circle = new Circle(response)
context.commit('addCircle', circle)
console.debug('Created circle', circleName, circle) console.debug('Created circle', circleName, circle)
return circle
} catch (error) { } catch (error) {
console.error(error) console.error(error)
showError(t('contacts', 'Unable to create circle {circleName}', { circleName })) showError(t('contacts', 'Unable to create circle {circleName}', { circleName }))
@ -169,16 +182,23 @@ const actions = {
}, },
/** /**
* Add a member to a circle * Add members to a circle
* *
* @param {Object} context the store mutations Current context * @param {Object} context the store mutations Current context
* @param {Object} data destructuring object * @param {Object} data destructuring object
* @param {string} data.circleId the circle to manage * @param {string} data.circleId the circle to manage
* @param {string} data.memberId the member to add * @param {Array} data.selection the members to add, see addMembers service
* @returns {Member[]}
*/ */
async addMemberToCircle(context, { circleId, memberId }) { async addMembersToCircle(context, { circleId, selection }) {
await this.addMember(circleId, memberId) const circle = context.getters.getCircle(circleId)
console.debug('Added member', circleId, memberId) const results = await addMembers(circleId, selection)
const members = results.map(member => new Member(member, circle))
console.debug('Added members to circle', circle, members)
context.commit('appendMembersToCircle', members)
return members
}, },
/** /**

View File

@ -66,7 +66,7 @@
</template> </template>
<script> <script>
import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS } from '../models/constants' import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, ROUTE_CIRCLE } from '../models/constants.ts'
import AppNavigationNew from '@nextcloud/vue/dist/Components/AppNavigationNew' import AppNavigationNew from '@nextcloud/vue/dist/Components/AppNavigationNew'
import Content from '@nextcloud/vue/dist/Components/Content' import Content from '@nextcloud/vue/dist/Components/Content'
@ -133,6 +133,9 @@ export default {
addressbooks() { addressbooks() {
return this.$store.getters.getAddressbooks return this.$store.getters.getAddressbooks
}, },
contacts() {
return this.$store.getters.getContacts
},
sortedContacts() { sortedContacts() {
return this.$store.getters.getSortedContacts return this.$store.getters.getSortedContacts
}, },
@ -186,6 +189,10 @@ export default {
} }
return [] return []
}, },
ungroupedContacts() {
return this.sortedContacts.filter(contact => this.contacts[contact.key].groups && this.contacts[contact.key].groups.length === 0)
},
}, },
watch: { watch: {
@ -343,10 +350,14 @@ export default {
} }
// Unknown group // Unknown group
if (!this.groups.find(group => group.name === this.selectedGroup) if (!this.selectedCircle
&& this.GROUP_ALL_CONTACTS !== this.selectedGroup && !this.groups.find(group => group.name === this.selectedGroup)
&& this.GROUP_NO_GROUP_CONTACTS !== this.selectedGroup) { && GROUP_ALL_CONTACTS !== this.selectedGroup
showError(t('contacts', 'Group not found')) && GROUP_NO_GROUP_CONTACTS !== this.selectedGroup
&& ROUTE_CIRCLE !== this.selectedGroup) {
showError(t('contacts', 'Group {group} not found', { group: this.selectedGroup }))
console.error('Group not found', this.selectedGroup)
this.$router.push({ this.$router.push({
name: 'root', name: 'root',
}) })

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"module": "ES6",
"moduleResolution": "node",
"target": "ES6",
"strictNullChecks": true,
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"declaration": true,
},
"include": [
"./src/**/*"
]
}

View File

@ -8,4 +8,12 @@ webpackConfig.plugins.push(...[
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
]) ])
webpackConfig.module.rules.push({
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
})
webpackConfig.resolve.extensions = ['.js', '.vue', '.ts', '.tsx']
module.exports = webpackConfig module.exports = webpackConfig