Skip to content
Open
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
74 changes: 53 additions & 21 deletions src/components/EnvelopeList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@
v-if="isAtLeastOneSelectedFavorite"
variant="tertiary"
:title="n('mail', 'Unfavorite {number}', 'Unfavorite {number}', selection.length, { number: selection.length })"
@click.prevent="favoriteAll">
@click.prevent="unfavoriteAll">
<IconUnFavorite :size="20" />
</NcButton>

<NcButton
v-if="isAtLeastOneSelectedUnFavorite"
variant="tertiary"
:title="n('mail', 'Favorite {number}', 'Favorite {number}', selection.length, { number: selection.length })"
@click.prevent="unFavoriteAll">
@click.prevent="favoriteAll">
<IconFavorite :size="20" />
</NcButton>

Expand Down Expand Up @@ -172,6 +172,7 @@ import AlertOctagonIcon from 'vue-material-design-icons/AlertOctagonOutline.vue'
import IconSelect from 'vue-material-design-icons/CloseThick.vue'
import EmailRead from 'vue-material-design-icons/EmailOpenOutline.vue'
import EmailUnread from 'vue-material-design-icons/EmailOutline.vue'
import ImportantIcon from 'vue-material-design-icons/LabelVariant.vue'
import ImportantOutlineIcon from 'vue-material-design-icons/LabelVariantOutline.vue'
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
import AddIcon from 'vue-material-design-icons/Plus.vue'
Expand Down Expand Up @@ -204,6 +205,7 @@ export default {
ActionButton,
Envelope,
IconDelete,
ImportantIcon,
ImportantOutlineIcon,
IconFavorite,
IconSelect,
Expand Down Expand Up @@ -263,11 +265,20 @@ export default {
type: Boolean,
default: false,
},

selection: {
type: Array,
default: () => [],
},

flatIndex: {
type: Number,
default: 0,
},
},

data() {
return {
selection: [],
showMoveModal: false,
showTagModal: false,
lastToggledIndex: undefined,
Expand Down Expand Up @@ -359,10 +370,27 @@ export default {
},

watch: {
selection: {
handler(newSelection) {
// Sync flags.selected with the global selection prop.
// This ensures checkboxes stay correct when another
// EnvelopeList instance changes the selection (e.g. shift-click
// across groups, or Select All / Unselect All).
const selectionSet = new Set(newSelection)
this.sortedEnvelops.forEach((env) => {
env.flags.selected = selectionSet.has(env.databaseId)
})
},
immediate: true,
},

sortedEnvelops(newVal, oldVal) {
// Unselect vanished envelopes
const newIds = newVal.map((env) => env.databaseId)
this.selection = this.selection.filter((id) => newIds.includes(id))
// Unselect vanished envelopes by emitting cleaned selection
const newIds = new Set(newVal.map((env) => env.databaseId))
const cleanedSelection = this.selection.filter((id) => newIds.has(id))
if (cleanedSelection.length !== this.selection.length) {
this.$emit('update:selection', cleanedSelection, this.envelopes)
}
differenceWith((a, b) => a.databaseId === b.databaseId, oldVal, newVal)
.forEach((env) => {
env.flags.selected = false
Expand Down Expand Up @@ -451,23 +479,21 @@ export default {
this.unselectAll()
},

favoriteAll() {
const favFlag = !this.isAtLeastOneSelectedUnFavorite
unfavoriteAll() {
this.selectedEnvelopes.forEach((envelope) => {
this.mainStore.markEnvelopeFavoriteOrUnfavorite({
envelope,
favFlag,
favFlag: false,
})
})
this.unselectAll()
},

unFavoriteAll() {
const favFlag = !this.isAtLeastOneSelectedFavorite
favoriteAll() {
this.selectedEnvelopes.forEach((envelope) => {
this.mainStore.markEnvelopeFavoriteOrUnfavorite({
envelope,
favFlag,
favFlag: true,
})
})
this.unselectAll()
Expand Down Expand Up @@ -537,16 +563,22 @@ export default {
const alreadySelected = this.selection.includes(envelope.databaseId)
if (selected && !alreadySelected) {
envelope.flags.selected = true
this.selection.push(envelope.databaseId)
} else if (!selected && alreadySelected) {
envelope.flags.selected = false
this.selection.splice(this.selection.indexOf(envelope.databaseId), 1)
}
},

emitLocalSelection() {
const localIds = this.sortedEnvelops
.filter((env) => env.flags.selected)
.map((env) => env.databaseId)
this.$emit('update:selection', localIds, this.envelopes)
},

onEnvelopeSelectToggle(envelope, index, selected) {
this.lastToggledIndex = index
this.setEnvelopeSelected(envelope, selected)
this.emitLocalSelection()
},

onEnvelopeSelectMultiple(envelope, index) {
Expand All @@ -557,20 +589,20 @@ export default {
return
}

const start = Math.min(lastToggledIndex, index)
const end = Math.max(lastToggledIndex, index)
const selected = this.selection.includes(envelope.databaseId)
for (let i = start; i <= end; i++) {
this.setEnvelopeSelected(this.sortedEnvelops[i], !selected)
}
// Convert to global flat indices and delegate to the parent
const globalFrom = this.flatIndex + lastToggledIndex
const globalTo = this.flatIndex + index
// If the clicked envelope is already selected, deselect the range
const deselect = this.selection.includes(envelope.databaseId)
this.$emit('select-range', globalFrom, globalTo, deselect)
this.lastToggledIndex = index
},

unselectAll() {
this.sortedEnvelops.forEach((env) => {
env.flags.selected = false
})
this.selection = []
this.$emit('update:selection', [], this.envelopes)
},

onOpenMoveModal() {
Expand Down
147 changes: 144 additions & 3 deletions src/components/Mailbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@
:slow-hint="t('mail', 'Indexing your messages. This can take a bit longer for larger folders.')" />
<EmptyMailboxSection v-else-if="isPriorityInbox && !hasMessages" key="empty" />
<EmptyMailbox v-else-if="!hasMessages" key="empty" />
<template v-else-if="hasGroupedEnvelopes && !isPriorityInbox">
<div v-else>
<div v-if="!selectMode" class="select-all-bar">
<NcCheckboxRadioSwitch
:model-value="false"
type="checkbox"
@update:checked="selectAll">
{{ n('mail', 'Select {count} message', 'Select all {count} messages', flatEnvelopeList.length, { count: flatEnvelopeList.length }) }}
</NcCheckboxRadioSwitch>
</div>
<template v-if="hasGroupedEnvelopes && !isPriorityInbox">
<div v-for="[label, group] in groupEnvelopes" :key="label">
<SectionTitle class="section-title" :name="getLabelForGroup(label)" />
<EnvelopeList
Expand All @@ -28,7 +37,11 @@
:loading-more="false"
:load-more-button="false"
:skip-transition="skipListTransition"
@delete="onDelete" />
:selection="selection"
:flat-index="getGroupFlatIndex(label)"
@delete="onDelete"
@update:selection="onUpdateSelection"
@select-range="onSelectRange" />
</div>
</template>
<EnvelopeList
Expand All @@ -41,8 +54,13 @@
:loading-more="loadingMore"
:load-more-button="showLoadMore"
:skip-transition="skipListTransition"
:selection="selection"
:flat-index="0"
@delete="onDelete"
@load-more="loadMore" />
@load-more="loadMore"
@update:selection="onUpdateSelection"
@select-range="onSelectRange" />
</div>
</div>
</template>

Expand All @@ -56,6 +74,7 @@ import EnvelopeList from './EnvelopeList.vue'
import Error from './Error.vue'
import Loading from './Loading.vue'
import LoadingSkeleton from './LoadingSkeleton.vue'
import { NcCheckboxRadioSwitch } from '@nextcloud/vue'
import SectionTitle from './SectionTitle.vue'
import MailboxLockedError from '../errors/MailboxLockedError.js'
import MailboxNotCachedError from '../errors/MailboxNotCachedError.js'
Expand All @@ -76,6 +95,7 @@ export default {
Error,
Loading,
LoadingSkeleton,
NcCheckboxRadioSwitch,
SectionTitle,
},

Expand Down Expand Up @@ -141,6 +161,7 @@ export default {
endReached: false,
syncedMailboxes: new Set(),
skipListTransition: false,
selection: [],
}
},

Expand Down Expand Up @@ -175,10 +196,37 @@ export default {
showLoadMore() {
return !this.endReached && this.paginate === 'manual'
},

/**
* Flat list of all visible envelopes, regardless of grouping.
* Used for shift-click range selection and Select All.
*/
flatEnvelopeList() {
if (this.hasGroupedEnvelopes) {
return this.groupEnvelopes.flatMap(([, group]) => group)
}
return this.envelopesToShow
},

/**
* Whether selection mode is active (at least one envelope selected).
*/
selectMode() {
return this.selection.length > 0
},

/**
* Whether all visible envelopes are currently selected.
*/
allSelected() {
return this.flatEnvelopeList.length > 0
&& this.selection.length === this.flatEnvelopeList.length
},
},

watch: {
mailbox() {
this.selection = []
this.loadEnvelopes()
.then(() => {
logger.debug(`syncing mailbox ${this.mailbox.databaseId} (${this.query}) after folder change`)
Expand All @@ -187,10 +235,12 @@ export default {
},

searchQuery() {
this.selection = []
this.loadEnvelopes()
},

sortOrder() {
this.selection = []
this.loadEnvelopes()
},
},
Expand Down Expand Up @@ -542,6 +592,9 @@ export default {
// onDelete(id): Load more message and navigate to other message if needed
// id: The id of the message being delete
onDelete(id) {
// Remove from selection if selected
this.selection = this.selection.filter((selectedId) => selectedId !== id)

// Get a new message
this.mainStore.fetchNextEnvelopes({
mailboxId: this.mailbox.databaseId,
Expand Down Expand Up @@ -603,6 +656,82 @@ export default {
this.loadMailboxInterval = undefined
},

/**
* Compute the flat index offset for a group label.
* Used by grouped EnvelopeList children to emit
* correct global indices for shift-click range selection.
*
* @param {string} label The group label key
* @return {number} Flat index of the first envelope in this group
*/
getGroupFlatIndex(label) {
let offset = 0
for (const [groupLabel, group] of this.groupEnvelopes) {
if (groupLabel === label) {
return offset
}
offset += group.length
}
return offset
},

/**
* Handle a child EnvelopeList updating its selection.
* The child emits its full new selection array (local IDs),
* and we merge it with the global selection, replacing
* any IDs from this child's visible envelope set.
*
* @param {number[]} childSelection Array of selected envelope databaseIds
* @param {object[]} childEnvelopes Array of envelopes visible in this child
*/
onUpdateSelection(childSelection, childEnvelopes) {
const childIds = new Set(childEnvelopes.map((e) => e.databaseId))
// Remove all IDs from this child's scope, then add the new selection
this.selection = this.selection.filter((id) => !childIds.has(id))
this.selection.push(...childSelection)
},

/**
* Handle shift-click range selection across the flat envelope list.
* Called by a child EnvelopeList with global flat indices.
*
* @param {number} fromIndex Start of the range (global flat index)
* @param {number} toIndex End of the range (global flat index)
* @param {boolean} deselect If true, remove the range from selection
*/
onSelectRange(fromIndex, toIndex, deselect = false) {
const start = Math.min(fromIndex, toIndex)
const end = Math.max(fromIndex, toIndex)
const idsInRange = new Set(
this.flatEnvelopeList
.slice(start, end + 1)
.map((e) => e.databaseId),
)
if (deselect) {
this.selection = this.selection.filter((id) => !idsInRange.has(id))
} else {
const newSelection = new Set(this.selection)
for (const id of idsInRange) {
newSelection.add(id)
}
this.selection = [...newSelection]
}
},

/**
* Select all visible envelopes.
*/
selectAll() {
this.selection = this.flatEnvelopeList.map((e) => e.databaseId)
},

/**
* Clear the current selection.
*/
unselectAll() {
this.selection = []
},

getLabelForGroup(group) {
switch (group) {
case 'lastHour':
Expand Down Expand Up @@ -636,4 +765,16 @@ export default {
display: flex;
justify-content: center;
}

.select-all-bar {
display: flex;
align-items: center;
margin-top: 8px;
padding: 4px 8px;
cursor: pointer;
border-bottom: 1px solid var(--color-border);
&:hover {
background-color: var(--color-background-hover);
}
}
</style>