Skip to content

Commit

Permalink
Add move shortcut to the SearchView
Browse files Browse the repository at this point in the history
Before few places in the code used different and slightly invalid
checks for moving mails, e.g. single and multiple mail selections were
different, or the check for the same mailbox was done but the check for
the correct folder was omitted.

This change unifies the implementations to always use the same logic.

fix #4510
  • Loading branch information
nokhub committed Jan 30, 2023
1 parent 9ca0389 commit 72a1b53
Show file tree
Hide file tree
Showing 11 changed files with 299 additions and 229 deletions.
2 changes: 1 addition & 1 deletion src/api/common/mail/FolderSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MailFolder } from "../../entities/tutanota/TypeRefs.js"
import { MailFolderType } from "../TutanotaConstants.js"
import { elementIdPart, getElementId, isSameId } from "../utils/EntityUtils.js"

interface IndentedFolder {
export interface IndentedFolder {
level: number
folder: MailFolder
}
Expand Down
14 changes: 14 additions & 0 deletions src/api/common/utils/EntityUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
base64ToUint8Array,
base64UrlToBase64,
hexToBase64,
isSameTypeRef,
pad,
stringToUtf8Uint8Array,
TypeRef,
Expand All @@ -15,6 +16,7 @@ import {
import { Cardinality, ValueType } from "../EntityConstants"
import type { ModelValue, SomeEntity, TypeModel } from "../EntityTypes"
import { ElementEntity } from "../EntityTypes"
import { ProgrammingError } from "../error/ProgrammingError.js"

/**
* the maximum ID for elements stored on the server (number with the length of 10 bytes) => 2^80 - 1
Expand Down Expand Up @@ -317,3 +319,15 @@ export function isValidGeneratedId(id: Id | IdTuple): boolean {
export function isElementEntity(e: SomeEntity): e is ElementEntity {
return typeof e._id === "string"
}

export function assertIsEntity<T extends SomeEntity>(entity: SomeEntity, type: TypeRef<T>): entity is T {
if (isSameTypeRef(entity._type, type)) {
return true
} else {
throw new ProgrammingError(`Entity is not of correct type ${type}`)
}
}

export function assertIsEntity2<T extends SomeEntity>(type: TypeRef<T>): (entity: SomeEntity) => entity is T {
return (e): e is T => assertIsEntity(e, type)
}
10 changes: 9 additions & 1 deletion src/mail/model/MailModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import stream from "mithril/stream"
import Stream from "mithril/stream"
import { containsEventOfType } from "../../api/common/utils/Utils"
import { assertNotNull, groupBy, neverNull, noOp, ofClass, splitInChunks } from "@tutao/tutanota-utils"
import type { Mail, MailBox, MailboxGroupRoot, MailboxProperties, MailFolder } from "../../api/entities/tutanota/TypeRefs.js"
import type { Mail, MailBox, MailboxGroupRoot, MailboxProperties, MailDetails, MailFolder } from "../../api/entities/tutanota/TypeRefs.js"
import {
createMailAddressProperties,
createMailboxProperties,
Expand Down Expand Up @@ -139,6 +139,14 @@ export class MailModel {
return this.mailboxDetails()
}

getMailboxDetailsForMailSync(mail: Mail): MailboxDetail | null {
const mailboxDetails = this.mailboxDetails()
if (mailboxDetails == null) {
return null
}
return assertNotNull(mailboxDetails.find((md) => md.folders.getFolderByMailListId(getListId(mail))))
}

getMailboxDetailsForMail(mail: Mail): Promise<MailboxDetail> {
return this.getMailboxDetailsForMailListId(mail._id[0])
}
Expand Down
57 changes: 37 additions & 20 deletions src/mail/model/MailUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@ import {
ReplyType,
TUTANOTA_MAIL_ADDRESS_DOMAINS,
} from "../../api/common/TutanotaConstants"
import { assertNotNull, contains, endsWith, first, neverNull, noOp, ofClass } from "@tutao/tutanota-utils"
import { assertNotNull, contains, endsWith, neverNull, noOp, ofClass } from "@tutao/tutanota-utils"
import { assertMainOrNode, isDesktop } from "../../api/common/Env"
import { LockedError, NotFoundError } from "../../api/common/error/RestError"
import type { LoginController } from "../../api/main/LoginController"
import type { Language, TranslationKey } from "../../misc/LanguageViewModel"
import { lang } from "../../misc/LanguageViewModel"
import { Icons } from "../../gui/base/icons/Icons"
import type { MailboxDetail } from "./MailModel"
import { MailModel } from "./MailModel"
import type { AllIcons } from "../../gui/base/Icon"
import type { GroupInfo, User } from "../../api/entities/sys/TypeRefs.js"
import { CustomerPropertiesTypeRef } from "../../api/entities/sys/TypeRefs.js"
Expand All @@ -36,10 +35,11 @@ import type { EntityClient } from "../../api/common/EntityClient"
import { getEnabledMailAddressesForGroupInfo, getGroupInfoDisplayName } from "../../api/common/utils/GroupUtils"
import { fullNameToFirstAndLastName, mailAddressToFirstAndLastName } from "../../misc/parsing/MailAddressParser"
import type { Attachment } from "../editor/SendMailModel"
import { elementIdPart, getListId, listIdPart } from "../../api/common/utils/EntityUtils"
import { elementIdPart, getListId, isSameId, listIdPart } from "../../api/common/utils/EntityUtils"
import { isDetailsDraft, isLegacyMail, MailWrapper } from "../../api/common/MailWrapper.js"
import { getLegacyMailHeaders, getMailHeaders } from "../../api/common/utils/Utils.js"
import { FolderSystem } from "../../api/common/mail/FolderSystem.js"
import { FolderSystem, IndentedFolder } from "../../api/common/mail/FolderSystem.js"
import { MailModel } from "./MailModel"

assertMainOrNode()
export const LINE_BREAK = "<br>"
Expand Down Expand Up @@ -263,14 +263,6 @@ export function markMails(entityClient: EntityClient, mails: Mail[], unread: boo
).then(noOp)
}

/**
* Check if all mails in the selection are drafts. If there are mixed drafts and non-drafts or the array is empty, return true.
* @param mails
*/
export function emptyOrContainsDraftsAndNonDrafts(mails: ReadonlyArray<Mail>): boolean {
return mails.length === 0 || (mails.some((mail) => mail.state === MailState.DRAFT) && mails.some((mail) => mail.state !== MailState.DRAFT))
}

/**
* Return true if all mails in the array are allowed to go inside the folder (e.g. drafts can go in drafts but not inbox)
* @param mails
Expand Down Expand Up @@ -298,6 +290,39 @@ export function mailStateAllowedInsideFolderType(mailState: string, folderType:
}
}

/**
* Return the mailbox if all mails belong to the same mailbox and null otherwise
* @param mails
*/
function getMailsCommonMailbox(mailModel: MailModel, mails: Mail[]): MailboxDetail | null {
let selectedMailbox: MailboxDetail | null = null

for (const mail of mails) {
const mailBox = mailModel.getMailboxDetailsForMailSync(mail)

// We can't move if mails are from different mailboxes
if (selectedMailbox != null && selectedMailbox !== mailBox) {
return null
}

selectedMailbox = mailBox
}

return selectedMailbox
}

export function getMailMoveTargets(mailModel: MailModel, mails: Mail[]): IndentedFolder[] {
const commonMailbox = getMailsCommonMailbox(mailModel, mails)
if (commonMailbox == null) {
return []
}

return commonMailbox.folders.getIndentedList().filter((folderInfo) => {
// folder is allowed target and there are selected mails that are not in this folder (mails that are already in this folder will be ignored)
return allMailsAllowedInsideFolder(mails, folderInfo.folder) && mails.some((mail) => !isSameId(getListId(mail), folderInfo.folder.mails))
})
}

export function copyMailAddress({ address, name }: EncryptedMailAddress): EncryptedMailAddress {
return createEncryptedMailAddress({
address,
Expand Down Expand Up @@ -388,14 +413,6 @@ export enum RecipientField {
BCC = "bcc",
}

export async function getMoveTargetFolderSystems(model: MailModel, mails: Mail[]): Promise<{ level: number; folder: MailFolder }[]> {
const firstMail = first(mails)
if (firstMail == null) return []

const targetFolders = (await model.getMailboxDetailsForMail(firstMail)).folders.getIndentedList().filter((f) => f.folder.mails !== getListId(firstMail))
return targetFolders.filter((f) => allMailsAllowedInsideFolder([firstMail], f.folder))
}

export const MAX_FOLDER_INDENT_LEVEL = 10

export function getIndentedFolderNameForDropdown(folderInfo: { level: number; folder: MailFolder }) {
Expand Down
44 changes: 25 additions & 19 deletions src/mail/view/MailGuiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { createMail } from "../../api/entities/tutanota/TypeRefs.js"
import { LockedError, PreconditionFailedError } from "../../api/common/error/RestError"
import { Dialog } from "../../gui/base/Dialog"
import { locator } from "../../api/main/MainLocator"
import { emptyOrContainsDraftsAndNonDrafts, getFolderIcon, getIndentedFolderNameForDropdown, getMoveTargetFolderSystems } from "../model/MailUtils"
import { getFolderIcon, getIndentedFolderNameForDropdown, getMailMoveTargets } from "../model/MailUtils"
import { AllIcons } from "../../gui/base/Icon"
import { Icons } from "../../gui/base/icons/Icons"
import type { InlineImages } from "./MailViewer"
import { isApp, isDesktop } from "../../api/common/Env"
import { assertNotNull, neverNull, promiseMap } from "@tutao/tutanota-utils"
import { assertNotNull, neverNull, noOp, promiseMap } from "@tutao/tutanota-utils"
import { MailFolderType, MailReportType } from "../../api/common/TutanotaConstants"
import { getElementId, getListId } from "../../api/common/utils/EntityUtils"
import { reportMailsAutomatically } from "./MailReportDialog"
Expand Down Expand Up @@ -292,23 +292,29 @@ export function getReferencedAttachments(attachments: Array<TutanotaFile>, refer
return attachments.filter((file) => referencedCids.find((rcid) => file.cid === rcid))
}

export function showMoveMailsDropdown(model: MailModel, origin: PosRect, mails: Mail[], width: number = 300, withBackground: boolean = false) {
if (emptyOrContainsDraftsAndNonDrafts(mails)) {
// do not move mails if no mails or mails cannot be moved together
return
}
export function showMoveMailsDropdown(
model: MailModel,
origin: PosRect,
mails: Mail[],
opts?: { width?: number; withBackground?: boolean; onSelected?: () => unknown },
) {
const { width = 300, withBackground = false, onSelected = noOp } = opts ?? {}
const allowedFolders = getMailMoveTargets(model, mails).map((f) => ({
label: () => getIndentedFolderNameForDropdown(f),
click: () => {
onSelected()
moveMails({ mailModel: model, mails: mails, targetMailFolder: f.folder })
},
icon: getFolderIcon(f.folder),
size: ButtonSize.Compact,
}))

getMoveTargetFolderSystems(locator.mailModel, mails).then((folders) => {
const dropdown = new Dropdown(() => {
return folders.map((f) => ({
label: () => getIndentedFolderNameForDropdown(f),
click: () => moveMails({ mailModel: locator.mailModel, mails: mails, targetMailFolder: f.folder }),
icon: getFolderIcon(f.folder),
size: ButtonSize.Compact,
}))
}, width)
if (allowedFolders.length === 0) return

dropdown.setOrigin(new DomRectReadOnlyPolyfilled(origin.left, origin.top, origin.width, 0))
modal.displayUnique(dropdown, withBackground)
})
const dropdown = new Dropdown(() => {
return allowedFolders
}, width)

dropdown.setOrigin(new DomRectReadOnlyPolyfilled(origin.left, origin.top, origin.width, 0))
modal.displayUnique(dropdown, withBackground)
}
6 changes: 5 additions & 1 deletion src/mail/view/MobileMailActionBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ export class MobileMailActionBar implements Component<MobileMailActionBarAttrs>
private moveButton({ viewModel }: MobileMailActionBarAttrs) {
return m(IconButton, {
title: "move_action",
click: (e, dom) => showMoveMailsDropdown(viewModel.mailModel, dom.getBoundingClientRect(), [viewModel.mail], this.dropdownWidth(), true),
click: (e, dom) =>
showMoveMailsDropdown(viewModel.mailModel, dom.getBoundingClientRect(), [viewModel.mail], {
width: this.dropdownWidth(),
withBackground: true,
}),
icon: Icons.Folder,
})
}
Expand Down
79 changes: 29 additions & 50 deletions src/mail/view/MultiMailViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ActionBar } from "../../gui/base/ActionBar"
import ColumnEmptyMessageBox from "../../gui/base/ColumnEmptyMessageBox"
import { lang } from "../../misc/LanguageViewModel"
import { Icons } from "../../gui/base/icons/Icons"
import { allMailsAllowedInsideFolder, emptyOrContainsDraftsAndNonDrafts, getFolderIcon, getIndentedFolderNameForDropdown, markMails } from "../model/MailUtils"
import { getFolderIcon, getIndentedFolderNameForDropdown, getMailMoveTargets, markMails } from "../model/MailUtils"
import { logins } from "../../api/main/LoginController"
import { FeatureType } from "../../api/common/TutanotaConstants"
import { BootIcons } from "../../gui/base/icons/BootIcons"
Expand All @@ -16,9 +16,8 @@ import { moveMails, promptAndDeleteMails } from "./MailGuiUtils"
import { attachDropdown, DropdownButtonAttrs } from "../../gui/base/Dropdown.js"
import { exportMails } from "../export/Exporter"
import { showProgressDialog } from "../../gui/dialogs/ProgressDialog"
import { MailboxDetail } from "../model/MailModel.js"
import { IconButtonAttrs } from "../../gui/base/IconButton.js"
import { haveSameId } from "../../api/common/utils/EntityUtils.js"
import { ListElement } from "../../api/common/utils/EntityUtils.js"

assertMainOrNode()

Expand Down Expand Up @@ -80,6 +79,20 @@ export class MultiMailViewer implements Component {
getActionBarButtons(prependCancel: boolean = false): IconButtonAttrs[] {
const selectedMails = this._mailView.cache.mailList?.list.getSelectedEntities() ?? []

const moveTargets = this.makeMoveMailButtons(selectedMails)
const move =
moveTargets.length === 0
? []
: [
attachDropdown({
mainButtonAttrs: {
title: "move_action",
icon: Icons.Folder,
},
childAttrs: () => moveTargets,
}),
]

const cancel: IconButtonAttrs[] = prependCancel
? [
{
Expand All @@ -90,19 +103,6 @@ export class MultiMailViewer implements Component {
]
: []

// if we have both drafts and non-drafts selected, then there is no good place to move them besides deleting them since drafts otherwise only go to the drafts folder and non-drafts do not
const move: IconButtonAttrs[] = !emptyOrContainsDraftsAndNonDrafts(selectedMails)
? [
attachDropdown({
mainButtonAttrs: {
title: "move_action",
icon: Icons.Folder,
},
childAttrs: () => this.makeMoveMailButtons(selectedMails),
}),
]
: []

return [
...cancel,
{
Expand Down Expand Up @@ -146,41 +146,20 @@ export class MultiMailViewer implements Component {
/**
* Generate buttons that will move the selected mails to respective folders
*/
private async makeMoveMailButtons(selectedEntities: Mail[]): Promise<DropdownButtonAttrs[]> {
let selectedMailbox: MailboxDetail | null = null

for (const mail of selectedEntities) {
const mailBox = await locator.mailModel.getMailboxDetailsForMail(mail)

// We can't move if mails are from different mailboxes
if (selectedMailbox != null && selectedMailbox !== mailBox) {
return []
private makeMoveMailButtons(selectedEntities: Mail[]): DropdownButtonAttrs[] {
return getMailMoveTargets(locator.mailModel, selectedEntities).map((folderInfo) => {
return {
label: () => getIndentedFolderNameForDropdown(folderInfo),
click: this._actionBarAction((mails) =>
moveMails({
mailModel: locator.mailModel,
mails: mails,
targetMailFolder: folderInfo.folder,
}),
),
icon: getFolderIcon(folderInfo.folder),
}

selectedMailbox = mailBox
}

if (selectedMailbox == null) return []
return selectedMailbox.folders
.getIndentedList()
.filter(
(folderInfo) =>
allMailsAllowedInsideFolder(selectedEntities, folderInfo.folder) &&
(this._mailView.cache.selectedFolder == null || !haveSameId(folderInfo.folder, this._mailView.cache.selectedFolder)),
)
.map((folderInfo) => {
return {
label: () => getIndentedFolderNameForDropdown(folderInfo),
click: this._actionBarAction((mails) =>
moveMails({
mailModel: locator.mailModel,
mails: mails,
targetMailFolder: folderInfo.folder,
}),
),
icon: getFolderIcon(folderInfo.folder),
}
})
})
}

/**
Expand Down

0 comments on commit 72a1b53

Please sign in to comment.