Skip to content

Commit

Permalink
Export individual mails works on conversation groups
Browse files Browse the repository at this point in the history
  • Loading branch information
wrdhub authored and charlag committed Feb 6, 2025
1 parent 273731f commit 3797817
Show file tree
Hide file tree
Showing 12 changed files with 258 additions and 260 deletions.
2 changes: 1 addition & 1 deletion src/mail-app/mail/export/Exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function getMailExportMode(): Promise<MailExportMode> {
* was instructed to open the new zip File containing the exported files
*/
export async function exportMails(
mails: Array<Mail>,
mails: ReadonlyArray<Mail>,
mailFacade: MailFacade,
entityClient: EntityClient,
fileController: FileController,
Expand Down
53 changes: 42 additions & 11 deletions src/mail-app/mail/model/MailModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
splitInChunks,
} from "@tutao/tutanota-utils"
import {
ConversationEntry,
ConversationEntryTypeRef,
Mail,
MailboxGroupRoot,
MailboxProperties,
Expand All @@ -41,7 +43,7 @@ import { WebsocketCounterData } from "../../../common/api/entities/sys/TypeRefs.
import { Notifications, NotificationType } from "../../../common/gui/Notifications.js"
import { lang } from "../../../common/misc/LanguageViewModel.js"
import { ProgrammingError } from "../../../common/api/common/error/ProgrammingError.js"
import { LockedError, NotFoundError, PreconditionFailedError } from "../../../common/api/common/error/RestError.js"
import { LockedError, NotAuthorizedError, NotFoundError, PreconditionFailedError } from "../../../common/api/common/error/RestError.js"
import { UserError } from "../../../common/api/main/UserError.js"
import { EventController } from "../../../common/api/main/EventController.js"
import { InboxRuleHandler } from "./InboxRuleHandler.js"
Expand All @@ -51,7 +53,6 @@ import { LoginController } from "../../../common/api/main/LoginController.js"
import { MailFacade } from "../../../common/api/worker/facades/lazy/MailFacade.js"
import { assertSystemFolderOfType } from "./MailUtils.js"
import { isSpamOrTrashFolder } from "./MailChecks.js"
import { MailItem } from "../view/ConversationViewModel"

interface MailboxSets {
folders: FolderSystem
Expand Down Expand Up @@ -433,11 +434,6 @@ export class MailModel {
return !this.logins.isEnabled(FeatureType.DisableMailExport)
}

async loadAndMarkMails(mails: () => Promise<readonly Mail[]>, unread: boolean): Promise<void> {
const loadedMails = await mails()
this.markMails(loadedMails, unread)
}

async markMails(mails: readonly Mail[], unread: boolean): Promise<void> {
await promiseMap(
mails,
Expand All @@ -451,10 +447,6 @@ export class MailModel {
)
}

async loadAndApplyLabels(mails: () => Promise<readonly Mail[]>, addedLabels: readonly MailFolder[], removedLabels: readonly MailFolder[]) {
this.applyLabels(await mails(), addedLabels, removedLabels)
}

async applyLabels(mails: readonly Mail[], addedLabels: readonly MailFolder[], removedLabels: readonly MailFolder[]): Promise<void> {
const groupedByListIds = groupBy(
mails.map((m) => m._id),
Expand Down Expand Up @@ -652,4 +644,43 @@ export class MailModel {
getImportedMailSets(): Array<MailFolder> {
return [...this.mailSets.values()].filter((f) => f.folders.importedMailSet).map((f) => f.folders.importedMailSet!)
}

async loadConversationsForAllMails(mails: ReadonlyArray<Mail>): Promise<ReadonlyArray<Mail>> {
let conversationEntries: ConversationEntry[] = []
for (const mail of mails) {
await this.entityClient.loadAll(ConversationEntryTypeRef, listIdPart(mail.conversationEntry)).then(
async (entries) => {
conversationEntries.push(...entries)
},
async (e) => {
// Most likely the conversation entry list does not exist anymore. The server does not distinguish between the case when the
// list does not exist and when we have no permission on it (and for good reasons, it prevents enumeration).
// Most often it happens when we are not fully synced with the server yet and the primary mail does not even exist.
if (!(e instanceof NotAuthorizedError)) {
throw e
}
},
)
}

// If there are no conversationEntries (somehow they didn't load), just return the mails back
if (conversationEntries.length < 0) {
return mails
}

const byList = groupBy(conversationEntries, (c) => c.mail && listIdPart(c.mail))
const allMails: Mail[] = []
for (const [listId, conversations] of byList.entries()) {
if (!listId) continue
const loaded = await this.entityClient.loadMultiple(
MailTypeRef,
listId,
conversations.map((c) => elementIdPart(assertNotNull(c.mail))),
)

allMails.push(...loaded)
}

return allMails
}
}
15 changes: 0 additions & 15 deletions src/mail-app/mail/view/ConversationViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export class ConversationViewModel {
private loadingPromise: Promise<void> | null = null
/** Is not set until {@link loadConversation is finished. Until it is finished we display primary mail and subject. */
private conversation: ConversationItem[] | null = null
private allConversationMails: Mail[] | null = null

constructor(
private options: CreateMailViewerOptions,
Expand Down Expand Up @@ -194,7 +193,6 @@ export class ConversationViewModel {
try {
if (this.conversationPrefProvider.getConversationViewShowOnlySelectedMail()) {
this.conversation = this.conversationItemsForSelectedMailOnly()
this.allConversationMails = [this._primaryViewModel.mail]
} else {
// Catch errors but only for loading conversation entries.
// if success, proceed with loading mails
Expand All @@ -206,7 +204,6 @@ export class ConversationViewModel {
return this.conversationItemsForSelectedMailOnly()
} else {
const allMails = await this.loadMails(entries)
this.allConversationMails = Array.from(allMails.values())
return this.createConversationItems(entries, allMails)
}
},
Expand Down Expand Up @@ -284,18 +281,6 @@ export class ConversationViewModel {
return this.conversation ?? this.conversationItemsForSelectedMailOnly()
}

/*
If ConversationInListView is active, all mails in the conversation are returned (so they can be processed in a group)
If not, only the primary mail is returned, since that is the one being looked at/interacted with.
*/
getActionableMails(): ReadonlyArray<Mail> {
if (this.conversationPrefProvider.getMailListDisplayMode() === MailListDisplayMode.CONVERSATIONS && this.allConversationMails) {
return this.allConversationMails
} else {
return [this._primaryViewModel.mail]
}
}

private conversationItemsForSelectedMailOnly(): ConversationItem[] {
return [
{
Expand Down
1 change: 0 additions & 1 deletion src/mail-app/mail/view/MailGuiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import { FontIcons } from "../../../common/gui/base/icons/FontIcons.js"
import { ProgrammingError } from "../../../common/api/common/error/ProgrammingError.js"
import { isOfTypeOrSubfolderOf, isSpamOrTrashFolder } from "../model/MailChecks.js"
import type { FolderSystem, IndentedFolder } from "../../../common/api/common/mail/FolderSystem.js"
import { LabelsPopup } from "./LabelsPopup"

export async function showDeleteConfirmationDialog(mails: ReadonlyArray<Mail>): Promise<boolean> {
let trashMails: Mail[] = []
Expand Down
19 changes: 10 additions & 9 deletions src/mail-app/mail/view/MailView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,8 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
mailboxModel: viewModel.primaryViewModel().mailboxModel,
mailModel: viewModel.primaryViewModel().mailModel,
mailViewerViewModel: viewModel.primaryViewModel(),
mails: [viewModel.primaryMail],
actionApplyMails: async () => viewModel.getActionableMails(),
selectedMails: [viewModel.primaryMail],
actionableMails: async () => this.mailViewModel.getActionableMails([viewModel.primaryMail]),
})
}

Expand Down Expand Up @@ -296,9 +296,9 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
return m(MailViewerActions, {
mailboxModel: locator.mailboxModel,
mailModel: mailLocator.mailModel,
mails: this.mailViewModel.listModel?.getSelectedAsArray() ?? [],
selectedMails: this.mailViewModel.listModel?.getSelectedAsArray() ?? [],
selectNone: () => this.mailViewModel.listModel?.selectNone(),
actionApplyMails: () => this.mailViewModel.getActionableMails(this.mailViewModel.listModel?.getSelectedAsArray() ?? []),
actionableMails: () => this.mailViewModel.getActionableMails(this.mailViewModel.listModel?.getSelectedAsArray() ?? []),
})
}

Expand Down Expand Up @@ -371,15 +371,16 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
styles.isSingleColumnLayout() && this.viewSlider.focusedColumn === this.mailColumn && this.conversationViewModel
? m(MobileMailActionBar, {
viewModel: this.conversationViewModel.primaryViewModel(),
actionApplyMails: async () => assertNotNull(this.conversationViewModel).getActionableMails(),
actionableMails: () =>
this.mailViewModel.getActionableMails(this.conversationViewModel ? [this.conversationViewModel.primaryMail] : []),
})
: styles.isSingleColumnLayout() && this.mailViewModel.listModel?.isInMultiselect()
? m(MobileMailMultiselectionActionBar, {
mails: this.mailViewModel.listModel.getSelectedAsArray(),
selectedMails: this.mailViewModel.listModel.getSelectedAsArray(),
selectNone: () => this.mailViewModel.listModel?.selectNone(),
mailModel: mailLocator.mailModel,
mailboxModel: locator.mailboxModel,
actionApplyMails: () => this.mailViewModel.getActionableMails(this.mailViewModel.listModel?.getSelectedAsArray() ?? []),
actionableMails: () => this.mailViewModel.getActionableMails(this.mailViewModel.listModel?.getSelectedAsArray() ?? []),
})
: m(BottomNav),
}),
Expand Down Expand Up @@ -593,8 +594,8 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
styles.isDesktopLayout() ? 300 : 200,
mailLocator.mailModel.getLabelsForMails(selectedMails),
mailLocator.mailModel.getLabelStatesForMails(selectedMails),
(addedLabels, removedLabels) =>
mailLocator.mailModel.loadAndApplyLabels(() => this.mailViewModel.getActionableMails(selectedMails), addedLabels, removedLabels),
async (addedLabels, removedLabels) =>
mailLocator.mailModel.applyLabels(await this.mailViewModel.getActionableMails(selectedMails), addedLabels, removedLabels),
)
popup.show()
}
Expand Down
44 changes: 4 additions & 40 deletions src/mail-app/mail/view/MailViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,53 +327,17 @@ export class MailViewModel {
}

/*
If ConversationInListView is active, all mails in the conversation are returned (so they can be processed in a group)
If not, only the primary mail is returned, since that is the one being looked at/interacted with.
*/
If ConversationInListView is active, all mails in the conversation are returned (so they can be processed in a group)
If not, only the primary mail is returned, since that is the one being looked at/interacted with.
*/
async getActionableMails(mails: Mail[]): Promise<ReadonlyArray<Mail>> {
if (this.conversationPrefProvider.getMailListDisplayMode() === MailListDisplayMode.CONVERSATIONS) {
return this.loadConversationsForAllMails(mails)
return this.mailModel.loadConversationsForAllMails(mails)
} else {
return mails
}
}

async loadConversationsForAllMails(mails: Mail[]): Promise<ReadonlyArray<Mail>> {
let conversationEntries: ConversationEntry[] = []
for (const mail of mails) {
await this.entityClient.loadAll(ConversationEntryTypeRef, listIdPart(mail.conversationEntry)).then(
async (entries) => {
conversationEntries.push(...entries)
},
async (e) => {
if (e instanceof NotAuthorizedError) {
// Most likely the conversation entry list does not exist anymore. The server does not distinguish between the case when the
// list does not exist and when we have no permission on it (and for good reasons, it prevents enumeration).
// Most often it happens when we are not fully synced with the server yet and the primary mail does not even exist.
//FIXME: not sure what to do here
//return this.conversationItemsForSelectedMailOnly()
} else {
throw e
}
},
)
}

const byList = groupBy(conversationEntries, (c) => c.mail && listIdPart(c.mail))
const allMails: Mail[] = []
for (const [listId, conversations] of byList.entries()) {
if (!listId) continue
const loaded = await this.entityClient.loadMultiple(
MailTypeRef,
listId,
conversations.map((c) => elementIdPart(assertNotNull(c.mail))),
)

allMails.push(...loaded)
}
return allMails
}

private async getFolderForUserInbox(): Promise<MailFolder> {
const mailboxDetail = await this.mailboxModel.getUserMailboxDetails()
const folders = await this.mailModel.getMailboxFoldersForId(assertNotNull(mailboxDetail.mailbox.folders)._id)
Expand Down
4 changes: 2 additions & 2 deletions src/mail-app/mail/view/MailViewerHeader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { isNotNull, noOp, resolveMaybeLazy } from "@tutao/tutanota-utils"
import { IconButton } from "../../../common/gui/base/IconButton.js"
import { getConfidentialIcon, getFolderIconByType, isTutanotaTeamMail, promptAndDeleteMails, showMoveMailsDropdown } from "./MailGuiUtils.js"
import { BootIcons } from "../../../common/gui/base/icons/BootIcons.js"
import { editDraft, mailViewerMoreActions } from "./MailViewerUtils.js"
import { editDraft, singleMailViewerMoreActions } from "./MailViewerUtils.js"
import { liveDataAttrs } from "../../../common/gui/AriaUtils.js"
import { isKeyPressed } from "../../../common/misc/KeyManager.js"
import { AttachmentBubble, getAttachmentType } from "../../../common/gui/AttachmentBubble.js"
Expand Down Expand Up @@ -796,7 +796,7 @@ export class MailViewerHeader implements Component<MailViewerHeaderAttrs> {
icon: Icons.Trash,
})

actionButtons.push(...mailViewerMoreActions(viewModel))
actionButtons.push(...singleMailViewerMoreActions(viewModel))
}

return actionButtons
Expand Down
Loading

0 comments on commit 3797817

Please sign in to comment.