diff --git a/src/common/api/common/TutanotaConstants.ts b/src/common/api/common/TutanotaConstants.ts index 0fc772c962ed..a34e0d04d93a 100644 --- a/src/common/api/common/TutanotaConstants.ts +++ b/src/common/api/common/TutanotaConstants.ts @@ -1244,3 +1244,5 @@ export function asPublicKeyIdentifier(maybe: NumberString): PublicKeyIdentifierT export const CLIENT_ONLY_CALENDAR_BIRTHDAYS_BASE_ID = "clientOnly_birthdays" export const CLIENT_ONLY_CALENDARS: Map = new Map([[CLIENT_ONLY_CALENDAR_BIRTHDAYS_BASE_ID, "birthdayCalendar_label"]]) export const DEFAULT_CLIENT_ONLY_CALENDAR_COLORS: Map = new Map([[CLIENT_ONLY_CALENDAR_BIRTHDAYS_BASE_ID, "FF9933"]]) + +export const MAX_LABELS_PER_MAIL = 10 diff --git a/src/common/misc/TranslationKey.ts b/src/common/misc/TranslationKey.ts index c0cd64b03106..ee578ec66ece 100644 --- a/src/common/misc/TranslationKey.ts +++ b/src/common/misc/TranslationKey.ts @@ -1863,3 +1863,4 @@ export type TranslationKeyType = | "yourMessage_label" | "you_label" | "emptyString_msg" + | "maximumLabelsPerMailReached_msg" diff --git a/src/mail-app/mail/model/MailModel.ts b/src/mail-app/mail/model/MailModel.ts index e2e8111f45a4..6ea8cf2619e2 100644 --- a/src/mail-app/mail/model/MailModel.ts +++ b/src/mail-app/mail/model/MailModel.ts @@ -15,11 +15,8 @@ import { partition, promiseMap, splitInChunks, - TypeRef, } from "@tutao/tutanota-utils" import { - ImportedMailTypeRef, - ImportMailStateTypeRef, Mail, MailboxGroupRoot, MailboxProperties, @@ -40,7 +37,7 @@ import { import { CUSTOM_MIN_ID, elementIdPart, GENERATED_MAX_ID, getElementId, getListId, isSameId, listIdPart } from "../../../common/api/common/utils/EntityUtils.js" import { containsEventOfType, EntityUpdateData, isUpdateForTypeRef } from "../../../common/api/common/utils/EntityUpdateUtils.js" import m from "mithril" -import { createEntityUpdate, WebsocketCounterData } from "../../../common/api/entities/sys/TypeRefs.js" +import { WebsocketCounterData } from "../../../common/api/entities/sys/TypeRefs.js" import { Notifications, NotificationType } from "../../../common/gui/Notifications.js" import { lang } from "../../../common/misc/LanguageViewModel.js" import { ProgrammingError } from "../../../common/api/common/error/ProgrammingError.js" @@ -271,6 +268,15 @@ export class MailModel { }) } + getLabelsForMails(mails: readonly Mail[]): ReadonlyMap> { + const labelsForMails = new Map() + for (const mail of mails) { + labelsForMails.set(elementIdPart(mail._id), this.getLabelsForMail(mail)) + } + + return labelsForMails + } + /** * @return labels that are currently applied to {@param mail}. */ diff --git a/src/mail-app/mail/view/LabelsPopup.ts b/src/mail-app/mail/view/LabelsPopup.ts index 3b9849f333a4..d0e0e438fa16 100644 --- a/src/mail-app/mail/view/LabelsPopup.ts +++ b/src/mail-app/mail/view/LabelsPopup.ts @@ -4,16 +4,17 @@ import { focusNext, focusPrevious, Shortcut } from "../../../common/misc/KeyMana import { BaseButton, BaseButtonAttrs } from "../../../common/gui/base/buttons/BaseButton.js" import { PosRect, showDropdown } from "../../../common/gui/base/Dropdown.js" import { MailFolder } from "../../../common/api/entities/tutanota/TypeRefs.js" -import { px, size } from "../../../common/gui/size.js" +import { size } from "../../../common/gui/size.js" import { AllIcons, Icon, IconSize } from "../../../common/gui/base/Icon.js" import { Icons } from "../../../common/gui/base/icons/Icons.js" import { theme } from "../../../common/gui/theme.js" -import { Keys, TabIndex } from "../../../common/api/common/TutanotaConstants.js" -import { getElementId } from "../../../common/api/common/utils/EntityUtils.js" +import { Keys, MAX_LABELS_PER_MAIL, TabIndex } from "../../../common/api/common/TutanotaConstants.js" +import { elementIdPart, getElementId } from "../../../common/api/common/utils/EntityUtils.js" import { getLabelColor } from "../../../common/gui/base/Label.js" import { LabelState } from "../model/MailModel.js" import { AriaRole } from "../../../common/gui/AriaUtils.js" import { lang } from "../../../common/misc/LanguageViewModel.js" +import { noOp } from "@tutao/tutanota-utils" /** * Popup that displays assigned labels and allows changing them @@ -25,6 +26,7 @@ export class LabelsPopup implements ModalComponent { private readonly sourceElement: HTMLElement, private readonly origin: PosRect, private readonly width: number, + private readonly labelsForMails: ReadonlyMap>, private readonly labels: { label: MailFolder; state: LabelState }[], private readonly onLabelsApplied: (addedLabels: MailFolder[], removedLabels: MailFolder[]) => unknown, ) { @@ -55,12 +57,17 @@ export class LabelsPopup implements ModalComponent { } view(): void | Children { + const isMaxLabelsReached = this.isMaxLabelsReached() + return m(".flex.col.elevated-bg.abs.dropdown-shadow.pt-s.border-radius", { tabindex: TabIndex.Programmatic, role: AriaRole.Menu }, [ m( ".pb-s.scroll", this.labels.map((labelState) => { const { label, state } = labelState const color = theme.content_button + const canToggleLabel = state === LabelState.Applied || !isMaxLabelsReached + const opacity = !canToggleLabel ? 0.5 : undefined + return m( "label-item.flex.items-center.plr.state-bg.cursor-pointer", @@ -69,7 +76,8 @@ export class LabelsPopup implements ModalComponent { role: AriaRole.MenuItemCheckbox, tabindex: TabIndex.Default, "aria-checked": ariaCheckedForState(state), - onclick: () => this.toggleLabel(labelState), + "aria-disabled": !canToggleLabel, + onclick: canToggleLabel ? () => this.toggleLabel(labelState) : noOp, }, [ m(Icon, { @@ -77,16 +85,18 @@ export class LabelsPopup implements ModalComponent { size: IconSize.Medium, style: { fill: getLabelColor(label.color), + opacity, }, }), - m(".button-height.flex.items-center.ml.overflow-hidden", { style: { color } }, m(".text-ellipsis", label.name)), + m(".button-height.flex.items-center.ml.overflow-hidden", { style: { color, opacity } }, m(".text-ellipsis", label.name)), ], ) }), ), + isMaxLabelsReached && m(".small.center.statusTextColor.pb-s", lang.get("maximumLabelsPerMailReached_msg")), m(BaseButton, { label: "Apply", - text: "Apply", + text: lang.get("apply_action"), class: "limit-width noselect bg-transparent button-height text-ellipsis content-accent-fg flex items-center plr-button button-content justify-center border-top state-bg", onclick: () => { this.applyLabels() @@ -114,7 +124,34 @@ export class LabelsPopup implements ModalComponent { } } - private applyLabels() { + private isMaxLabelsReached(): boolean { + const { addedLabels, removedLabels } = this.getSortedLabels() + if (addedLabels.length >= MAX_LABELS_PER_MAIL) { + return true + } + + for (const [, labels] of this.labelsForMails) { + const labelsOnMail = new Set(labels.map((label) => elementIdPart(label._id))) + + for (const label of removedLabels) { + labelsOnMail.delete(elementIdPart(label._id)) + } + if (labelsOnMail.size >= MAX_LABELS_PER_MAIL) { + return true + } + + for (const label of addedLabels) { + labelsOnMail.add(elementIdPart(label._id)) + if (labelsOnMail.size >= MAX_LABELS_PER_MAIL) { + return true + } + } + } + + return false + } + + private getSortedLabels(): Record<"addedLabels" | "removedLabels", MailFolder[]> { const removedLabels: MailFolder[] = [] const addedLabels: MailFolder[] = [] for (const { label, state } of this.labels) { @@ -124,6 +161,11 @@ export class LabelsPopup implements ModalComponent { removedLabels.push(label) } } + return { addedLabels, removedLabels } + } + + private applyLabels() { + const { addedLabels, removedLabels } = this.getSortedLabels() this.onLabelsApplied(addedLabels, removedLabels) modal.remove(this) } diff --git a/src/mail-app/mail/view/MailView.ts b/src/mail-app/mail/view/MailView.ts index 93ebc1df1452..d888a4f225ab 100644 --- a/src/mail-app/mail/view/MailView.ts +++ b/src/mail-app/mail/view/MailView.ts @@ -1,9 +1,9 @@ import m, { Children, Vnode } from "mithril" import { ViewSlider } from "../../../common/gui/nav/ViewSlider.js" import { ColumnType, ViewColumn } from "../../../common/gui/base/ViewColumn" -import { lang, TranslationText } from "../../../common/misc/LanguageViewModel" +import { lang } from "../../../common/misc/LanguageViewModel" import { Dialog } from "../../../common/gui/base/Dialog" -import { FeatureType, HighestTierPlans, getMailFolderType, Keys, MailSetKind } from "../../../common/api/common/TutanotaConstants" +import { FeatureType, getMailFolderType, Keys, MailSetKind } from "../../../common/api/common/TutanotaConstants" import { AppHeaderAttrs, Header } from "../../../common/gui/Header.js" import { Mail, MailBox, MailFolder } from "../../../common/api/entities/tutanota/TypeRefs.js" import { isEmpty, noOp, ofClass } from "@tutao/tutanota-utils" @@ -588,6 +588,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView mailLocator.mailModel.applyLabels(selectedMails, addedLabels, removedLabels), ) diff --git a/src/mail-app/mail/view/MailViewerHeader.ts b/src/mail-app/mail/view/MailViewerHeader.ts index 3b50515adfdd..c41f8aec9cf5 100644 --- a/src/mail-app/mail/view/MailViewerHeader.ts +++ b/src/mail-app/mail/view/MailViewerHeader.ts @@ -770,6 +770,7 @@ export class MailViewerHeader implements Component { dom, dom.getBoundingClientRect(), styles.isDesktopLayout() ? 300 : 200, + viewModel.mailModel.getLabelsForMails([viewModel.mail]), viewModel.mailModel.getLabelStatesForMails([viewModel.mail]), (addedLabels, removedLabels) => viewModel.mailModel.applyLabels([viewModel.mail], addedLabels, removedLabels), ) diff --git a/src/mail-app/mail/view/MailViewerToolbar.ts b/src/mail-app/mail/view/MailViewerToolbar.ts index 2563d8fe5e7a..21c8bc9f9002 100644 --- a/src/mail-app/mail/view/MailViewerToolbar.ts +++ b/src/mail-app/mail/view/MailViewerToolbar.ts @@ -119,6 +119,7 @@ export class MailViewerActions implements Component { dom, dom.getBoundingClientRect(), styles.isDesktopLayout() ? 300 : 200, + mailModel.getLabelsForMails(mails), mailModel.getLabelStatesForMails(mails), (addedLabels, removedLabels) => mailModel.applyLabels(mails, addedLabels, removedLabels), ) diff --git a/src/mail-app/mail/view/MobileMailActionBar.ts b/src/mail-app/mail/view/MobileMailActionBar.ts index 875429498a11..1fd512fd6ca0 100644 --- a/src/mail-app/mail/view/MobileMailActionBar.ts +++ b/src/mail-app/mail/view/MobileMailActionBar.ts @@ -84,6 +84,7 @@ export class MobileMailActionBar implements Component referenceDom, referenceDom.getBoundingClientRect(), this.dropdownWidth() ?? 200, + viewModel.mailModel.getLabelsForMails([viewModel.mail]), viewModel.mailModel.getLabelStatesForMails([viewModel.mail]), (addedLabels, removedLabels) => viewModel.mailModel.applyLabels([viewModel.mail], addedLabels, removedLabels), ) diff --git a/src/mail-app/mail/view/MobileMailMultiselectionActionBar.ts b/src/mail-app/mail/view/MobileMailMultiselectionActionBar.ts index 9411336f9909..11dde0b8e187 100644 --- a/src/mail-app/mail/view/MobileMailMultiselectionActionBar.ts +++ b/src/mail-app/mail/view/MobileMailMultiselectionActionBar.ts @@ -58,6 +58,7 @@ export class MobileMailMultiselectionActionBar { referenceDom, referenceDom.getBoundingClientRect(), referenceDom.offsetWidth - DROPDOWN_MARGIN * 2, + mailModel.getLabelsForMails(mails), mailModel.getLabelStatesForMails(mails), (addedLabels, removedLabels) => mailModel.applyLabels(mails, addedLabels, removedLabels), ) diff --git a/src/mail-app/translations/de.ts b/src/mail-app/translations/de.ts index b68ed4b820b0..eee35407c503 100644 --- a/src/mail-app/translations/de.ts +++ b/src/mail-app/translations/de.ts @@ -1882,6 +1882,7 @@ export default { "yourCalendars_label": "Deine Kalender", "yourFolders_action": "DEINE ORDNER", "yourMessage_label": "Deine Nachricht", - "you_label": "Du" + "you_label": "Du", + "maximumLabelsPerMailReached_msg": "Maximum allowed labels per mail reached." } } diff --git a/src/mail-app/translations/de_sie.ts b/src/mail-app/translations/de_sie.ts index 4875754fe181..10d7e88c91c8 100644 --- a/src/mail-app/translations/de_sie.ts +++ b/src/mail-app/translations/de_sie.ts @@ -1882,6 +1882,7 @@ export default { "yourCalendars_label": "Deine Kalender", "yourFolders_action": "Ihre ORDNER", "yourMessage_label": "Ihre Nachricht", - "you_label": "Sie" + "you_label": "Sie", + "maximumLabelsPerMailReached_msg": "Maximum allowed labels per mail reached." } } diff --git a/src/mail-app/translations/en.ts b/src/mail-app/translations/en.ts index bbb2b62cfea1..584fae914b9e 100644 --- a/src/mail-app/translations/en.ts +++ b/src/mail-app/translations/en.ts @@ -1878,6 +1878,7 @@ export default { "yourCalendars_label": "Your calendars", "yourFolders_action": "YOUR FOLDERS", "yourMessage_label": "Your message", - "you_label": "You" + "you_label": "You", + "maximumLabelsPerMailReached_msg": "Maximum allowed labels per mail reached." } }