Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 181 additions & 3 deletions src/components/ContactsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,81 @@

<template>
<AppContentList class="content-list">
<NcDialog :open="showDeleteConfirmationDialog"
:name="n(
'contacts',
'Delete {number} contact',
'Delete {number} contacts',
multiSelectedContacts.size,
{ number: multiSelectedContacts.size }
)"
:buttons="buttons"
no-close>
{{ t('contacts', 'Are you sure you want to proceed?') }}
<NcNoteCard v-if="readOnlyMultiSelectedCount"
type="info"
:text="n('contacts',
'Please note that {number} contact is read only and won\'t be deleted',
'Please note that {number} contacts are read only and won\'t be deleted',
readOnlyMultiSelectedCount,
{ number: readOnlyMultiSelectedCount })" />
</NcDialog>

<div class="contacts-list__header">
<div class="search-contacts-field">
<input v-model="query" type="text" :placeholder="t('contacts', 'Search contacts …')">
</div>
</div>
<transition name="contacts-list__multiselect-header">
<div v-if="isMultiSelecting" class="contacts-list__multiselect-header">
<NcButton type="tertiary"
:title="t('contacts', 'Unselect {number}', { number: multiSelectedContacts.size })"
:close-after-click="true"
@click.prevent="unselectAllMultiSelected">
<IconSelect :size="16" />
</NcButton>
<NcButton type="tertiary"
:disabled="!isAtLeastOneEditable"
:title="deleteActionTitle"
:close-after-click="true"
@click.prevent="attemptDeleteAllMultiSelected">
<IconDelete :size="16" />
</NcButton>
</div>
</transition>

<VirtualList ref="scroller"
class="contacts-list"
data-key="key"
:data-sources="filteredList"
:data-component="ContactsListItem"
:estimate-size="68"
:extra-props="{reloadBus}" />
:extra-props="{reloadBus, onSelectMultipleFromParent: onSelectMultiple, onSelectRangeFromParent: onSelectRange }" />
</AppContentList>
</template>

<script>
import { NcAppContentList as AppContentList } from '@nextcloud/vue'
import { NcAppContentList as AppContentList, NcButton, NcDialog, NcNoteCard } from '@nextcloud/vue'
import ContactsListItem from './ContactsList/ContactsListItem.vue'
import VirtualList from 'vue-virtual-scroll-list'
import IconSelect from 'vue-material-design-icons/CloseThick.vue'
import IconDelete from 'vue-material-design-icons/Delete.vue'
// eslint-disable-next-line import/no-unresolved
import IconCancelRaw from '@mdi/svg/svg/cancel.svg?raw'
// eslint-disable-next-line import/no-unresolved
import IconDeleteRaw from '@mdi/svg/svg/delete.svg?raw'

export default {
name: 'ContactsList',

components: {
AppContentList,
NcNoteCard,
VirtualList,
NcButton,
IconSelect,
IconDelete,
NcDialog,
},

props: {
Expand All @@ -56,6 +105,22 @@ export default {
return {
ContactsListItem,
query: '',
multiSelectedContacts: new Map(),
showDeleteConfirmationDialog: false,
buttons: [
{
label: t('contacts', 'Cancel'),
icon: IconCancelRaw,
callback: () => { this.showDeleteConfirmationDialog = false },
},
{
label: t('contacts', 'Delete'),
type: 'primary',
icon: IconDeleteRaw,
callback: () => { this.deleteAllMultiSelected() },
},
],
lastToggledIndex: undefined,
}
},

Expand All @@ -67,9 +132,37 @@ export default {
return this.$route.params.selectedGroup
},
filteredList() {
return this.list
const contactsList = this.list
.filter(item => this.matchSearch(this.contacts[item.key]))
.map(item => this.contacts[item.key])

contactsList.forEach((contact, index) => {
contact.isMultiSelected = this.multiSelectedContacts.has(index)
})

return contactsList
},
isMultiSelecting() {
return this.multiSelectedContacts.size > 0
},
readOnlyMultiSelectedCount() {
let count = 0

this.multiSelectedContacts.forEach((contact) => {
if (contact.addressbook.readOnly) {
count++
}
})

return count
},
isAtLeastOneEditable() {
return this.readOnlyMultiSelectedCount !== this.multiSelectedContacts.size
},
deleteActionTitle() {
return this.isAtLeastOneEditable
? n('contacts', 'Delete {number} contact', 'Delete {number} contacts', this.multiSelectedContacts.size, { number: this.multiSelectedContacts.size })
: t('contacts', 'Please select at least one editable contact to delete')
},
},

Expand Down Expand Up @@ -145,6 +238,70 @@ export default {
}
return true
},

onSelectMultiple(contact, index, isRange = false) {
if (isRange && this.lastToggledIndex !== index) {
if (this.onSelectRange(index)) {
return
}
}

if (this.multiSelectedContacts.has(index)) {
this.multiSelectedContacts.delete(index)
} else {
this.multiSelectedContacts.set(index, contact)
}
this.lastToggledIndex = index
this.$set(this, 'multiSelectedContacts', new Map(this.multiSelectedContacts))
},

onSelectRange(index) {
const lastToggledIndex = this.lastToggledIndex ?? undefined
if (lastToggledIndex === undefined) {
return false
}

const start = Math.min(lastToggledIndex, index)
const end = Math.max(lastToggledIndex, index)
const selected = this.multiSelectedContacts.has(index)

const newSelection = new Map(this.multiSelectedContacts)

for (let i = start; i <= end; i++) {
if (!selected) {
newSelection.set(i, this.filteredList[i])
} else {
newSelection.delete(i)
}
}

this.lastToggledIndex = index
this.$set(this, 'multiSelectedContacts', newSelection)

return true
},

unselectAllMultiSelected() {
this.$set(this, 'multiSelectedContacts', new Map())
this.lastToggledIndex = undefined
},

attemptDeleteAllMultiSelected() {
this.showDeleteConfirmationDialog = true
},

deleteAllMultiSelected() {
this.multiSelectedContacts.forEach(async (contact) => {
if (contact.addressbook.readOnly) {
// Do not try to delete read only contacts
return
}
await new Promise(resolve => setTimeout(resolve, 500))
await this.$store.dispatch('deleteContact', { contact })
})
this.unselectAllMultiSelected()
this.showDeleteConfirmationDialog = false
},
},
}
</script>
Expand Down Expand Up @@ -175,4 +332,25 @@ export default {
padding: 0 var(--default-grid-baseline);
}

.contacts-list__multiselect-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background-color: var(--color-main-background-translucent);
position: sticky;
height: calc(var(--default-grid-baseline) * 12);
z-index: 100;
}

.contacts-list__multiselect-header-enter-active, .contacts-list__multiselect-header-leave-active {
transition: all calc(var(--animation-slow) / 2);
}

.contacts-list__multiselect-header-enter,
.contacts-list__multiselect-header-leave-to {
opacity: 0;
height: 0;
transform: scaleY(0);
}
</style>
58 changes: 54 additions & 4 deletions src/components/ContactsList/ContactsListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
<template>
<div class="contacts-list__item-wrapper"
:draggable="isDraggable"
@dragstart="startDrag($event, source)">
@dragstart="startDrag($event, source)"
@click.shift.exact.prevent="onSelectMultipleRange">
<ListItem :id="id"
:key="source.key"
class="list-item-style envelope"
Expand All @@ -14,8 +15,17 @@
<!-- @slot Icon slot -->

<template #icon>
<div class="app-content-list-item-icon">
<BaseAvatar :display-name="source.displayName" :url="avatarUrl" :size="40" />
<div class="contacts-list__item-icon"
@click.exact.prevent="onSelectMultiple"
@mouseenter="hoveringAvatar = true"
@mouseleave="hoveringAvatar = false">
<BaseAvatar v-if="!source.isMultiSelected && !hoveringAvatar"
:display-name="source.displayName"
:url="avatarUrl"
:size="40" />
<CheckIcon v-if="source.isMultiSelected || hoveringAvatar"
:size="28"
:class="{ 'contacts-list__item-avatar-selected': source.isMultiSelected, 'contacts-list__item-avatar-hovered': !source.isMultiSelected }" />
</div>
</template>
<template #subname>
Expand All @@ -34,13 +44,15 @@ import {
NcListItem as ListItem,
NcAvatar as BaseAvatar,
} from '@nextcloud/vue'
import CheckIcon from 'vue-material-design-icons/Check.vue'

export default {
name: 'ContactsListItem',

components: {
ListItem,
BaseAvatar,
CheckIcon,
},

props: {
Expand All @@ -56,10 +68,15 @@ export default {
type: Object,
required: true,
},
onSelectMultipleFromParent: {
type: Function,
default: () => {},
},
},
data() {
return {
avatarUrl: undefined,
hoveringAvatar: false,
}
},

Expand Down Expand Up @@ -156,13 +173,20 @@ export default {
params: { selectedGroup: this.selectedGroup, selectedContact: this.source.key },
})
},
onSelectMultiple() {
// This weirdness of passing a function as a prop is because the VirtualList extra-props prop object does not support listening to custom events (afaik)
Comment thread
GVodyanov marked this conversation as resolved.
this.onSelectMultipleFromParent(this.source, this.index)
},
onSelectMultipleRange() {
this.onSelectMultipleFromParent(this.source, this.index, true)
},
},
}
</script>
<style lang="scss" scoped>

.envelope {
.app-content-list-item-icon {
.contacts-list__item-icon {
height: 40px; // To prevent some unexpected spacing below the avatar
}

Expand Down Expand Up @@ -195,4 +219,30 @@ export default {
cursor: not-allowed !important;
}
}

.contacts-list__item-icon {
cursor: pointer !important;
}

.contacts-list__item-avatar {

&-selected, &-hovered {
border-radius: 32px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}

&-selected {
background-color: var(--color-primary-element);
color: var(--color-primary-light);
}

&-hovered {
color: var(--color-primary-hover);
background-color: var(--color-primary-light-hover);
}
}
</style>
Loading