From df5a120bc1f3406314105ad828d599f6242fff74 Mon Sep 17 00:00:00 2001 From: RobinAngele Date: Thu, 7 May 2026 11:07:44 +0200 Subject: [PATCH 01/35] fix: add missing ImportantIcon and correct favorite/unfavorite bulk actions - Add missing ImportantIcon import and component registration in EnvelopeList so the 'Mark as important' icon renders during bulk selection - Fix favorite/unfavorite bulk action logic: rename methods to favoriteAll/unfavoriteAll and use explicit favFlag values (true/false) instead of inverted computed checks that failed when all selected messages shared the same state AI-assisted: Cline (Claude) Signed-off-by: RobinAngele --- src/components/EnvelopeList.vue | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/EnvelopeList.vue b/src/components/EnvelopeList.vue index 71ae845f7d..3d4e6bf4f4 100644 --- a/src/components/EnvelopeList.vue +++ b/src/components/EnvelopeList.vue @@ -43,7 +43,7 @@ v-if="isAtLeastOneSelectedFavorite" variant="tertiary" :title="n('mail', 'Unfavorite {number}', 'Unfavorite {number}', selection.length, { number: selection.length })" - @click.prevent="favoriteAll"> + @click.prevent="unfavoriteAll"> @@ -51,7 +51,7 @@ v-if="isAtLeastOneSelectedUnFavorite" variant="tertiary" :title="n('mail', 'Favorite {number}', 'Favorite {number}', selection.length, { number: selection.length })" - @click.prevent="unFavoriteAll"> + @click.prevent="favoriteAll"> @@ -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' @@ -204,6 +205,7 @@ export default { ActionButton, Envelope, IconDelete, + ImportantIcon, ImportantOutlineIcon, IconFavorite, IconSelect, @@ -451,23 +453,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() From c5a8cfdfda3ee3ca2f1c4b7982a62ba6e3077c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Angel=C3=A9?= Date: Thu, 7 May 2026 05:34:48 +0200 Subject: [PATCH 02/35] feat: add select-all checkbox and lift selection state to parent Add a 'Select all X messages' checkbox above the envelope list using NcCheckboxRadioSwitch from @nextcloud/vue, allowing users to select all visible messages at once. Lift envelope selection state from individual EnvelopeList instances up to the Mailbox parent component. This enables: - Cross-group shift-click range selection via flat envelope indexing - Consistent selection state across grouped envelope lists - Global select-all / unselect-all from the parent level Also pass the selection array as a prop to EnvelopeList and add proper event handling for update:selection and select-range. Closes: #4285 Refs: #7880, #6070, #7276 AI-assisted: Cline (Claude) Signed-off-by: RobinAngele --- src/components/EnvelopeList.vue | 58 ++++++++++--- src/components/Mailbox.vue | 147 +++++++++++++++++++++++++++++++- 2 files changed, 189 insertions(+), 16 deletions(-) diff --git a/src/components/EnvelopeList.vue b/src/components/EnvelopeList.vue index 3d4e6bf4f4..4c418f54d0 100644 --- a/src/components/EnvelopeList.vue +++ b/src/components/EnvelopeList.vue @@ -265,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, @@ -361,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 @@ -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) { @@ -557,12 +589,12 @@ 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 }, @@ -570,7 +602,7 @@ export default { this.sortedEnvelops.forEach((env) => { env.flags.selected = false }) - this.selection = [] + this.$emit('update:selection', [], this.envelopes) }, onOpenMoveModal() { diff --git a/src/components/Mailbox.vue b/src/components/Mailbox.vue index 861db0a881..714eedc421 100644 --- a/src/components/Mailbox.vue +++ b/src/components/Mailbox.vue @@ -17,7 +17,16 @@ :slow-hint="t('mail', 'Indexing your messages. This can take a bit longer for larger folders.')" /> - @@ -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' @@ -76,6 +95,7 @@ export default { Error, Loading, LoadingSkeleton, + NcCheckboxRadioSwitch, SectionTitle, }, @@ -141,6 +161,7 @@ export default { endReached: false, syncedMailboxes: new Set(), skipListTransition: false, + selection: [], } }, @@ -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`) @@ -187,10 +235,12 @@ export default { }, searchQuery() { + this.selection = [] this.loadEnvelopes() }, sortOrder() { + this.selection = [] this.loadEnvelopes() }, }, @@ -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, @@ -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': @@ -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); + } +} From 754e83068ae6ec9ae34eac65f59ab528fe5114da Mon Sep 17 00:00:00 2001 From: RobinAngele Date: Thu, 7 May 2026 11:38:14 +0200 Subject: [PATCH 03/35] feat: add filter-based mass selection via search modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'Select all matching' button to the search parameters dialog and enable selecting all messages matching a filter across all pages. Search modal: - New 'Select all matching' button in SearchMessages.vue dialog - Emits 'select-all-matching' event via the MailboxThread bus Mass loading: - Mailbox.vue onBusSelectAllMatching forces a fresh load, then iterates loadMore() until all pages are fetched - Spinner + 'Selecting messages…' shown during loading - Checkbox disabled while loading Context-aware labels: - 'Select N loaded messages' when more pages exist - 'Select N matching messages' when a filter is active - 'Select all N messages' when all pages are loaded - Hint row: 'Scroll down to include more messages, use filter to refine, or click an avatar circle to select one at a time' Depends on: #12900 (select-all checkbox feature) Closes: #4285, #7880 Refs: #6070, #7276, #11526 AI-assisted: Cline (Claude) Signed-off-by: RobinAngele --- src/components/Mailbox.vue | 172 ++++++++++++++++++++++++++++-- src/components/MailboxThread.vue | 8 +- src/components/SearchMessages.vue | 16 ++- 3 files changed, 186 insertions(+), 10 deletions(-) diff --git a/src/components/Mailbox.vue b/src/components/Mailbox.vue index 714eedc421..6850222715 100644 --- a/src/components/Mailbox.vue +++ b/src/components/Mailbox.vue @@ -18,13 +18,33 @@
-
- - {{ n('mail', 'Select {count} message', 'Select all {count} messages', flatEnvelopeList.length, { count: flatEnvelopeList.length }) }} - + + {{ selectAllLabel }} + +
+ + {{ t('mail', 'Selecting messages…') }} +
+
+ {{ selectAllHint }} +
+
+ + {{ n('mail', + 'All {visible} message on this page selected. Select all {total} matching this filter?', + 'All {visible} messages on this page selected. Select all {total} matching this filter?', + totalEnvelopeCount, + { visible: flatEnvelopeList.length, total: totalEnvelopeCount }) }} + + {{ n('mail', 'Select all {count} message', 'Select all {count} messages', totalEnvelopeCount, { count: totalEnvelopeCount }) }} +