diff --git a/packages/tutanota-utils/lib/Utils.ts b/packages/tutanota-utils/lib/Utils.ts index f82194111ca8..3ebbcc87c2a7 100644 --- a/packages/tutanota-utils/lib/Utils.ts +++ b/packages/tutanota-utils/lib/Utils.ts @@ -1,4 +1,5 @@ import { TypeRef } from "./TypeRef.js" +import { arrayEquals } from "./ArrayUtils.js" export interface ErrorInfo { readonly name: string | null @@ -217,19 +218,21 @@ export function makeSingleUse(fn: Callback): Callback { * If the cached argument has changed then {@param fn} will be called with new argument and result will be cached again. * Only remembers the last argument. */ -export function memoized(fn: (arg0: T) => R): (arg0: T) => R { - let lastArg: T - let lastResult: R +export function memoized any>(fn: F): F { + let lastArgs: unknown[] + let lastResult: Parameters let didCache = false - return (arg) => { - if (!didCache || arg !== lastArg) { - lastArg = arg + + const memoizedFunction = (...args: Parameters) => { + if (!didCache || !arrayEquals(lastArgs, args)) { + lastArgs = args didCache = true - lastResult = fn(arg) + lastResult = fn(...args) } return lastResult } + return memoizedFunction as F } /** diff --git a/packages/tutanota-utils/test/UtilsTest.ts b/packages/tutanota-utils/test/UtilsTest.ts index b41cfdd0c90b..2ce2c9dc3365 100644 --- a/packages/tutanota-utils/test/UtilsTest.ts +++ b/packages/tutanota-utils/test/UtilsTest.ts @@ -1,6 +1,8 @@ import o from "@tutao/otest" -import { clone, deepEqual, getChangedProps } from "../lib/Utils.js" +import { clone, deepEqual, getChangedProps, memoized } from "../lib/Utils.js" import { arrayEquals } from "../lib/index.js" +import { func, matchers, when } from "testdouble" +import { verify } from "@tutao/tutanota-test-utils" o.spec("utils", function () { o("deep clone an instance", function () { @@ -142,4 +144,46 @@ o.spec("utils", function () { o(getChangedProps(undefined, undefined)).deepEquals([]) o(getChangedProps(null, null)).deepEquals([]) }) + + o.spec("memoized", function () { + o.test("when called twice with the same argument it is called once", function () { + const fnToMemoize = func<(_: number) => number>() + when(fnToMemoize(matchers.anything())).thenReturn(42) + const memoizedFn = memoized(fnToMemoize) + o(memoizedFn(1)).equals(42) + o(memoizedFn(1)).equals(42) + verify(fnToMemoize(matchers.anything()), { times: 1 }) + }) + + o.test("when called twice with the different arguments it is called twice", function () { + const fnToMemoize = func<(_: number) => number>() + when(fnToMemoize(1)).thenReturn(11) + when(fnToMemoize(2)).thenReturn(12) + const memoizedFn = memoized(fnToMemoize) + o(memoizedFn(1)).equals(11) + o(memoizedFn(2)).equals(12) + verify(fnToMemoize(1), { times: 1 }) + verify(fnToMemoize(2), { times: 1 }) + }) + + o.test("when called twice with the same arguments it is called once", function () { + const fnToMemoize = func<(l: number, r: number) => number>() + when(fnToMemoize(matchers.anything(), matchers.anything())).thenReturn(42) + const memoizedFn = memoized(fnToMemoize) + o(memoizedFn(1, 2)).equals(42) + o(memoizedFn(1, 2)).equals(42) + verify(fnToMemoize(matchers.anything(), matchers.anything()), { times: 1 }) + }) + + o.test("when called twice with different arguments it is called twice", function () { + const fnToMemoize = func<(l: number, r: number) => number>() + when(fnToMemoize(1, 1)).thenReturn(11) + when(fnToMemoize(1, 2)).thenReturn(12) + const memoizedFn = memoized(fnToMemoize) + o(memoizedFn(1, 1)).equals(11) + o(memoizedFn(1, 2)).equals(12) + verify(fnToMemoize(1, 1), { times: 1 }) + verify(fnToMemoize(1, 2), { times: 1 }) + }) + }) }) diff --git a/src/common/misc/DeviceConfig.ts b/src/common/misc/DeviceConfig.ts index 699a01ba300b..2363996ef17d 100644 --- a/src/common/misc/DeviceConfig.ts +++ b/src/common/misc/DeviceConfig.ts @@ -22,6 +22,10 @@ export enum ListAutoSelectBehavior { OLDER, NEWER, } +export const enum MailListDisplayMode { + CONVERSATIONS = "conversations", + MAILS = "mails", +} export type LastExternalCalendarSyncEntry = { lastSuccessfulSync: number | undefined | null @@ -53,6 +57,7 @@ interface ConfigObject { _testAssignments: PersistedAssignmentData | null offlineTimeRangeDaysByUser: Record conversationViewShowOnlySelectedMail: boolean + mailListDisplayMode: MailListDisplayMode /** Stores each users' definition about contact synchronization */ syncContactsWithPhonePreference: Record /** Whether mobile calendar navigation is in the "per week" or "per month" mode */ @@ -87,13 +92,13 @@ interface ConfigObject { * Device config for internal user auto login. Only one config per device is stored. */ export class DeviceConfig implements UsageTestStorage, NewsItemStorage { - public static Version = 4 - public static LocalStorageKey = "tutanotaConfig" + public static readonly Version = 5 + public static readonly LocalStorageKey = "tutanotaConfig" private config!: ConfigObject private lastSyncStream: Stream> = stream(new Map()) - constructor(private readonly _version: number, private readonly localStorage: Storage | null) { + constructor(private readonly localStorage: Storage | null) { this.init() } @@ -134,6 +139,7 @@ export class DeviceConfig implements UsageTestStorage, NewsItemStorage { _signupToken: signupToken, offlineTimeRangeDaysByUser: loadedConfig.offlineTimeRangeDaysByUser ?? {}, conversationViewShowOnlySelectedMail: loadedConfig.conversationViewShowOnlySelectedMail ?? false, + mailListDisplayMode: loadedConfig.mailListDisplayMode ?? MailListDisplayMode.CONVERSATIONS, syncContactsWithPhonePreference: loadedConfig.syncContactsWithPhonePreference ?? {}, isCalendarDaySelectorExpanded: loadedConfig.isCalendarDaySelectorExpanded ?? false, mailAutoSelectBehavior: loadedConfig.mailAutoSelectBehavior ?? (isApp() ? ListAutoSelectBehavior.NONE : ListAutoSelectBehavior.OLDER), @@ -398,6 +404,15 @@ export class DeviceConfig implements UsageTestStorage, NewsItemStorage { this.writeToStorage() } + getMailListDisplayMode(): MailListDisplayMode { + return this.config.mailListDisplayMode + } + + setMailListDisplayMode(setting: MailListDisplayMode) { + this.config.mailListDisplayMode = setting + this.writeToStorage() + } + getUserSyncContactsWithPhonePreference(id: Id): boolean | null { return this.config.syncContactsWithPhonePreference[id] ?? null } @@ -510,6 +525,12 @@ export function migrateConfig(loadedConfig: any) { if (loadedConfig._version < 3) { migrateConfigV2to3(loadedConfig) } + + // version 4 had no migration + + if (loadedConfig._version < 5) { + loadedConfig.mailListDisplayMode = MailListDisplayMode.MAILS + } } /** @@ -558,4 +579,4 @@ export interface DeviceConfigCredentials { readonly encryptedPassphraseKey: Base64 | null } -export const deviceConfig: DeviceConfig = new DeviceConfig(DeviceConfig.Version, client.localStorage() ? localStorage : null) +export const deviceConfig: DeviceConfig = new DeviceConfig(client.localStorage() ? localStorage : null) diff --git a/src/common/misc/ListModel.ts b/src/common/misc/ListModel.ts index 446e35b865ad..b1b1b955ee99 100644 --- a/src/common/misc/ListModel.ts +++ b/src/common/misc/ListModel.ts @@ -62,7 +62,7 @@ type PrivateListState = Omit, "items" | "activeInd export class ListModel { constructor(private readonly config: ListModelConfig) {} - private loadState: "created" | "initialized" = "created" + private initialLoading: Promise | null = null private loading: Promise = Promise.resolve() private filter: ListFilter | null = null private rangeSelectionAnchorItem: ItemType | null = null @@ -115,7 +115,7 @@ export class ListModel { private waitUtilInit(): Promise { const deferred = defer() const subscription = this.rawStateStream.map(() => { - if (this.loadState === "initialized") { + if (this.initialLoading != null) { Promise.resolve().then(() => { subscription.end(true) deferred.resolve(undefined) @@ -126,25 +126,25 @@ export class ListModel { } async loadInitial() { - if (this.loadState !== "created") { - return + // execute the loading only once + if (this.initialLoading == null) { + this.initialLoading = this.doLoad() } - this.loadState = "initialized" - await this.doLoad() + await this.initialLoading } async loadMore() { if (this.rawState.loadingStatus === ListLoadingState.Loading) { return this.loading } - if (this.loadState !== "initialized" || this.rawState.loadingStatus !== ListLoadingState.Idle) { + if (this.initialLoading == null || this.rawState.loadingStatus !== ListLoadingState.Idle) { return } await this.doLoad() } async retryLoading() { - if (this.loadState !== "initialized" || this.rawState.loadingStatus !== ListLoadingState.ConnectionLost) { + if (this.initialLoading == null || this.rawState.loadingStatus !== ListLoadingState.ConnectionLost) { return } await this.doLoad() @@ -574,6 +574,8 @@ export class ListModel { canInsertItem(entity: ItemType): boolean { if (this.state.loadingStatus === ListLoadingState.Done) { + // If the entire list is loaded, it is always safe to add items, because we can assume we have the entire + // range loaded return true } diff --git a/src/common/misc/TranslationKey.ts b/src/common/misc/TranslationKey.ts index 82836cb91d70..fd5cfe4847d3 100644 --- a/src/common/misc/TranslationKey.ts +++ b/src/common/misc/TranslationKey.ts @@ -1889,6 +1889,10 @@ export type TranslationKeyType = | "yourMessage_label" | "you_label" | "emptyString_msg" + | "mailListGrouping_label" + | "mailListGroupingDontGroup_label" + | "mailListGroupingGroupByConversation_label" + | "mailListGroupingHelp_msg" // Put in temporarily, will be removed soon | "localAdminGroup_label" | "assignAdminRightsToLocallyAdministratedUserError_msg" diff --git a/src/common/settings/EditNotificationEmailDialog.ts b/src/common/settings/EditNotificationEmailDialog.ts index 19fb8b844440..fb1a0eb503cc 100644 --- a/src/common/settings/EditNotificationEmailDialog.ts +++ b/src/common/settings/EditNotificationEmailDialog.ts @@ -163,7 +163,7 @@ export function show(existingTemplate: NotificationMailTemplate | null, customer m.redraw() }) // Even though savedHtml is always sanitized changing it might lead to mXSS - const sanitizePreview = memoized((html) => { + const sanitizePreview = memoized<(html: string) => string>((html) => { return htmlSanitizer.sanitizeHTML(html).html }) diff --git a/src/mail-app/mail/model/ConversationListModel.ts b/src/mail-app/mail/model/ConversationListModel.ts new file mode 100644 index 000000000000..26b5126c34a7 --- /dev/null +++ b/src/mail-app/mail/model/ConversationListModel.ts @@ -0,0 +1,577 @@ +import { applyInboxRulesToEntries, LoadedMail, MailSetListModel, resolveMailSetEntries } from "./MailSetListModel" +import { ListLoadingState, ListState } from "../../../common/gui/base/List" +import { Mail, MailFolder, MailFolderTypeRef, MailSetEntry, MailSetEntryTypeRef, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs" +import { EntityUpdateData, isUpdateForTypeRef } from "../../../common/api/common/utils/EntityUpdateUtils" +import { ListFilter, ListModel } from "../../../common/misc/ListModel" +import Stream from "mithril/stream" +import { ConversationPrefProvider } from "../view/ConversationViewModel" +import { EntityClient } from "../../../common/api/common/EntityClient" +import { MailModel } from "./MailModel" +import { InboxRuleHandler } from "./InboxRuleHandler" +import { ExposedCacheStorage } from "../../../common/api/worker/rest/DefaultEntityRestCache" +import { + CUSTOM_MAX_ID, + customIdToUint8array, + deconstructMailSetEntryId, + elementIdPart, + firstBiggerThanSecondCustomId, + getElementId, + isSameId, + listIdPart, +} from "../../../common/api/common/utils/EntityUtils" +import { assertNotNull, compare, first, last, lastThrow, mapWithout, memoizedWithHiddenArgument, remove } from "@tutao/tutanota-utils" +import { ListFetchResult } from "../../../common/gui/base/ListUtils" +import { isOfflineError } from "../../../common/api/common/utils/ErrorUtils" +import { OperationType } from "../../../common/api/common/TutanotaConstants" + +/** + * @VisibleForTesting + */ +export interface LoadedConversation { + // list ID of the conversation + readonly conversationId: Id + + // element ID of the latest MailSetEntry of the conversation + readonly latestMail: Id +} + +/** + * Organizes mails into conversations and handles state upkeep. + */ +export class ConversationListModel implements MailSetListModel { + // Id = MailSetEntry element id (of latest conversation entry element) + // We want to use this as it ensures that conversations are still sorted in order of latest received email + private readonly listModel: ListModel + + // Map conversation IDs (to ensure unique conversations) + private readonly conversationMap: Map = new Map() + + // The last fetched mail set entry id; the list model does not track mailsets but conversations, thus we can't rely + // on it to give us the oldest retrieved mail. + private lastFetchedMailSetEntryId: Id | null = null + + // keep a reverse map for going from Mail element id -> LoadedMail + private mailMap: ReadonlyMap = new Map() + + // we may select a mail in a conversation that isn't the latest one (i.e. navigating to an older mail via URL); by + // default, we would show the latest mail, but in this case, we will want to present the older mail + // + // this is cleared upon changing the selection + private olderDisplayedSelectedMailOverride: Id | null = null + + constructor( + private readonly mailSet: MailFolder, + private readonly conversationPrefProvider: ConversationPrefProvider, + private readonly entityClient: EntityClient, + private readonly mailModel: MailModel, + private readonly inboxRuleHandler: InboxRuleHandler, + private readonly cacheStorage: ExposedCacheStorage, + ) { + this.listModel = new ListModel({ + fetch: async (_, count) => { + const lastFetchedId = this.lastFetchedMailSetEntryId ?? CUSTOM_MAX_ID + return this.loadMails([mailSet.entries, lastFetchedId], count) + }, + + sortCompare: (item1, item2) => this.reverseSortConversation(item1, item2), + + getItemId: (item) => this.listModelIdOfConversation(item), + + isSameId: (id1, id2) => id1 === id2, + + autoSelectBehavior: () => this.conversationPrefProvider.getMailAutoSelectBehavior(), + }) + } + + get lastItem(): Mail | null { + const lastItem = last(this.listModel.state.items) ?? null + return lastItem ? this.getLatestMailForConversation(lastItem).mail : null + } + + areAllSelected(): boolean { + return this.listModel.areAllSelected() + } + + cancelLoadAll(): void { + this.listModel.cancelLoadAll() + } + + enterMultiselect(): void { + this.listModel.enterMultiselect() + } + + getLabelsForMail(mail: Mail): ReadonlyArray { + return this.getLoadedMail(getElementId(mail))?.labels ?? [] + } + + getMail(mailId: Id): Mail | null { + return this.getLoadedMail(mailId)?.mail ?? null + } + + private getLoadedMail(mailId: Id): LoadedMail | null { + return this.mailMap.get(mailId) ?? null + } + + private getMailIdForConversation(conversation: LoadedConversation): Id { + return deconstructMailSetEntryId(conversation.latestMail).mailId + } + + private getLatestMailForConversation(conversation: LoadedConversation): LoadedMail { + return assertNotNull(this.getLoadedMail(this.getMailIdForConversation(conversation))) + } + + readonly getSelectedAsArray = memoizedWithHiddenArgument( + () => this.listModel.getSelectedAsArray(), + (conversations) => conversations.map((conversation) => this.getLatestMailForConversation(conversation).mail), + ) + + async handleEntityUpdate(update: EntityUpdateData) { + if (isUpdateForTypeRef(MailFolderTypeRef, update)) { + if (update.operation === OperationType.UPDATE) { + this.handleMailFolderUpdate(update) + } + } else if (isUpdateForTypeRef(MailSetEntryTypeRef, update) && isSameId(this.mailSet.entries, update.instanceListId)) { + if (update.operation === OperationType.DELETE) { + await this.handleMailSetEntryDeletion(update) + } else if (update.operation === OperationType.CREATE) { + await this.handleMailSetEntryCreation(update) + } + } else if (isUpdateForTypeRef(MailTypeRef, update)) { + // We only need to handle updates for Mail. + // Mail deletion will also be handled in MailSetEntry delete/create. + const mailItem = this.mailMap.get(update.instanceId) + if (mailItem != null && (update.operation === OperationType.UPDATE || update.operation === OperationType.CREATE)) { + await this.handleMailUpdate(update, mailItem) + } + } + } + + private handleMailFolderUpdate(update: EntityUpdateData) { + // If a label is modified, we want to update all mails that reference it, which requires linearly iterating + // through all mails. There are more efficient ways we could do this, such as by keeping track of each label + // we've retrieved from the database and just update that, but we want to avoid adding more maps that we + // have to maintain. + const mailSetId: IdTuple = [update.instanceListId, update.instanceId] + const mailsToUpdate: LoadedMail[] = [] + for (const loadedMail of this.mailMap.values()) { + const hasMailSet = loadedMail.labels.some((label) => isSameId(mailSetId, label._id)) + if (!hasMailSet) { + continue + } + // MailModel's entity event listener should have been fired first + const labels = this.mailModel.getLabelsForMail(loadedMail.mail) + const newMailEntry = { + ...loadedMail, + labels, + } + mailsToUpdate.push(newMailEntry) + } + this._updateMails(mailsToUpdate) + } + + private async handleMailUpdate(update: EntityUpdateData, mailItem: LoadedMail) { + const newMailData = await this.entityClient.load(MailTypeRef, [update.instanceListId, update.instanceId]) + const labels = this.mailModel.getLabelsForMail(newMailData) // in case labels were added/removed + const loadedMail = { + ...mailItem, + labels, + mail: newMailData, + } + this._updateMails([loadedMail]) + + // force an update for the conversation + const conversation = this.getConversationForMail(newMailData) + if (conversation != null && this.getLatestMailForConversation(conversation) === loadedMail) { + this.listModel.updateLoadedItem(conversation) + } + } + + private async handleMailSetEntryCreation(update: EntityUpdateData) { + const loadedMail = await this.loadSingleMail([update.instanceListId, update.instanceId]) + const addedMail = loadedMail.addedItems[0] + if (addedMail == null) { + return + } + return await this.listModel.waitLoad(async () => { + if (this.listModel.canInsertItem(addedMail)) { + this.listModel.insertLoadedItem(addedMail) + } + for (const oldEntry of loadedMail.deletedItems) { + const id = this.listModelIdOfConversation(oldEntry) + const wasSelected = this.listModel.isItemSelected(id) + const inMultiselect = this.isInMultiselect() + const selection = this.listModel.getSelectedAsArray() + + // ensure the selection does not change + if (wasSelected) { + if (inMultiselect) { + for (const s of selection) { + if (this.listModelIdOfConversation(s) !== this.listModelIdOfConversation(oldEntry)) { + this.listModel.onSingleInclusiveSelection(s) + } + } + this.listModel.onSingleInclusiveSelection(addedMail) + } else { + this.listModel.onSingleSelection(addedMail) + } + } + + await this.listModel.deleteLoadedItem(id) + } + }) + } + + private async handleMailSetEntryDeletion(update: EntityUpdateData) { + const { mailId } = deconstructMailSetEntryId(update.instanceId) + const conversation = this.getConversationForMailById(mailId) + if (!conversation) { + return + } + + await this.listModel.deleteLoadedItem(this.listModelIdOfConversation(conversation)) + this.deleteSingleMail(mailId) + + // The state changed in the meantime. We should not touch the conversation map. + if (this.conversationMap.get(conversation.conversationId)?.latestMail != conversation.latestMail) { + return + } + this.conversationMap.delete(conversation.conversationId) + + // If there is an earlier mail in the conversation that is in this mail set, we want it to take this one's place. + let latestMail: LoadedMail | null = null + for (const mail of this.mailMap.values()) { + if (isSameId(listIdPart(mail.mail.conversationEntry), conversation.conversationId)) { + if (latestMail == null || firstBiggerThanSecondCustomId(elementIdPart(mail.mailSetEntryId), elementIdPart(latestMail.mailSetEntryId))) { + // Important: Map iterates in insertion order, which does not correspond to list sorting. As + // such, we can't break here, as there may be even newer mails after this. + latestMail = mail + } + } + } + + if (latestMail) { + const newConversation = { + ...conversation, + latestMail: elementIdPart(latestMail.mailSetEntryId), + } + this.conversationMap.set(conversation.conversationId, newConversation) + this.listModel.waitLoad(() => { + if (this.conversationMap.get(conversation.conversationId) === newConversation && this.listModel.canInsertItem(newConversation)) { + this.listModel.insertLoadedItem(newConversation) + } + }) + } + } + + // @VisibleForTesting + _updateMails(loadedMails: ReadonlyArray) { + const newMap = new Map(this.mailMap) + for (const mail of loadedMails) { + newMap.set(getElementId(mail.mail), mail) + } + this.mailMap = newMap + } + + // Evict a mail from the list cache. + private deleteSingleMail(mailId: Id) { + this.mailMap = mapWithout(this.mailMap, mailId) + } + + private async loadSingleMail(id: IdTuple): Promise<{ + addedItems: LoadedConversation[] + deletedItems: LoadedConversation[] + }> { + const mailSetEntry = await this.entityClient.load(MailSetEntryTypeRef, id) + const loadedMails = await this.resolveMailSetEntries([mailSetEntry], this.defaultMailProvider) + return this.filterAndUpdateMails(loadedMails) + } + + isEmptyAndDone(): boolean { + return this.listModel.isEmptyAndDone() + } + + isInMultiselect(): boolean { + return this.listModel.state.inMultiselect + } + + isItemSelected(mailId: Id): boolean { + const conversation = this.getConversationForMailById(mailId) + return conversation != null && this.listModel.isItemSelected(this.listModelIdOfConversation(conversation)) + } + + isLoadingAll(): boolean { + return this.listModel.state.loadingAll + } + + get items(): ReadonlyArray { + return this._items() + } + + get mails(): ReadonlyArray { + return this._mails() + } + + async loadAll() { + await this.listModel.loadAll() + } + + async loadAndSelect(mailId: string, shouldStop: () => boolean): Promise { + const mailFinder = (loadedConversation: LoadedConversation) => isSameId(this.getMailIdForConversation(loadedConversation), mailId) + + // conversation listing has a special case: we may want to select an item that isn't on the list but is part of + // a conversation that is actually in the list; as such, we should disregard what listModel says + const stop = () => this.getMail(mailId) != null || shouldStop() + await this.listModel.loadAndSelect(mailFinder, stop) + + const selectedMail = this.getMail(mailId) + if (selectedMail != null) { + const selectedMailId = getElementId(selectedMail) + const conversation = assertNotNull(this.getConversationForMailById(selectedMailId)) + if (!isSameId(this.getMailIdForConversation(conversation), selectedMailId)) { + this.olderDisplayedSelectedMailOverride = selectedMailId + this.listModel.onSingleSelection(conversation) + } + } + return selectedMail + } + + async loadInitial() { + return this.listModel.loadInitial() + } + + async loadMore() { + await this.listModel.loadMore() + } + + get loadingStatus(): ListLoadingState { + return this.listModel.state.loadingStatus + } + + onSingleInclusiveSelection(mail: Mail, clearSelectionOnMultiSelectStart?: boolean): void { + this.listModel.onSingleInclusiveSelection(assertNotNull(this.getConversationForMail(mail)), clearSelectionOnMultiSelectStart) + } + + onSingleSelection(mail: Mail): void { + this.listModel.onSingleSelection(assertNotNull(this.getConversationForMail(mail))) + } + + async retryLoading() { + await this.listModel.retryLoading() + } + + selectAll(): void { + this.listModel.selectAll() + } + + selectNext(multiSelect: boolean): void { + this.listModel.selectNext(multiSelect) + } + + selectNone(): void { + this.listModel.selectNone() + } + + selectPrevious(multiSelect: boolean): void { + this.listModel.selectPrevious(multiSelect) + } + + selectRangeTowards(mail: Mail): void { + this.listModel.selectRangeTowards(assertNotNull(this.getConversationForMail(mail))) + } + + setFilter(filterType: ListFilter | null): void { + this.listModel.setFilter(filterType && ((conversation: LoadedConversation) => filterType(this.getLatestMailForConversation(conversation).mail))) + } + + get stateStream(): Stream> { + return this.listModel.stateStream.map((state) => { + if (this.olderDisplayedSelectedMailOverride) { + const olderMail = this.getMail(this.olderDisplayedSelectedMailOverride) + if ( + olderMail == null || + state.selectedItems.size !== 1 || + state.inMultiselect || + [...state.selectedItems][0].conversationId !== listIdPart(olderMail.conversationEntry) + ) { + this.olderDisplayedSelectedMailOverride = null + } + } + const newState: ListState = { + ...state, + items: this.items, + selectedItems: new Set(this.getSelectedAsArray()), + } + return newState + }) + } + + stopLoading(): void { + this.listModel.stopLoading() + } + + onSingleExclusiveSelection(mail: Mail): void { + this.listModel.onSingleExclusiveSelection(assertNotNull(this.getConversationForMail(mail))) + } + + getDisplayedMail(): Mail | null { + if (this.olderDisplayedSelectedMailOverride != null) { + return this.getMail(this.olderDisplayedSelectedMailOverride) + } else if (this.isInMultiselect()) { + return null + } else { + return first(this.getSelectedAsArray()) + } + } + + /** + * Gets the conversation if this mail is the latest of that conversation. + * + * Note: We do not care about older mails in the conversation - only the mail that is going to be shown in the list. + */ + private getConversationForMailById(mailId: Id): LoadedConversation | null { + const mail = this.mailMap.get(mailId) + if (mail == null) { + return null + } + return this.conversationMap.get(listIdPart(mail.mail.conversationEntry)) ?? null + } + + private getConversationForMail(mail: Mail): LoadedConversation | null { + return this.getConversationForMailById(getElementId(mail)) + } + + /** + * This is the list model ID in the conversation. + */ + private listModelIdOfConversation(item: LoadedConversation): Id { + return item.latestMail + } + + /** + * Load mails, applying inbox rules as needed + */ + private async loadMails(startingId: IdTuple, count: number): Promise> { + let items: LoadedMail[] = [] + let complete = false + + try { + const mailSetEntries = await this.entityClient.loadRange(MailSetEntryTypeRef, listIdPart(startingId), elementIdPart(startingId), count, true) + + // Check for completeness before loading/filtering mails, as we may end up with even fewer mails than retrieved in either case + complete = mailSetEntries.length < count + if (mailSetEntries.length > 0) { + this.lastFetchedMailSetEntryId = getElementId(lastThrow(mailSetEntries)) + items = await this.resolveMailSetEntries(mailSetEntries, this.defaultMailProvider) + items = await this.applyInboxRulesToEntries(items) + } + } catch (e) { + if (isOfflineError(e)) { + // Attempt loading from the cache if we failed to get mails and/or mailset entries + // Note that we may have items if it was just inbox rules that failed + if (items.length === 0) { + // Set the request as incomplete so that we make another request later (see `loadMailsFromCache` comment) + complete = false + items = await this.loadMailsFromCache(startingId, count) + if (items.length === 0) { + throw e // we couldn't get anything from the cache! + } + } + } else { + throw e + } + } + + return { + // there should be no deleted items since we're loading older mails + items: this.filterAndUpdateMails(items)?.addedItems, + complete, + } + } + + private async applyInboxRulesToEntries(entries: LoadedMail[]): Promise { + return applyInboxRulesToEntries(entries, this.mailSet, this.mailModel, this.inboxRuleHandler) + } + + private filterAndUpdateMails(mails: LoadedMail[]): { + addedItems: LoadedConversation[] + deletedItems: LoadedConversation[] + } { + const addedItems: LoadedConversation[] = [] + const deletedItems: LoadedConversation[] = [] + + // store all mails to be loaded later + this._updateMails(mails) + + for (const mail of mails) { + const conversation = { + conversationId: listIdPart(mail.mail.conversationEntry), + latestMail: elementIdPart(mail.mailSetEntryId), + } + + const existingConversation = this.conversationMap.get(conversation.conversationId) + if (existingConversation != null && this.reverseSortConversation(conversation, existingConversation) >= 0) { + continue + } + + if (existingConversation != null) { + deletedItems.push(existingConversation) + remove(addedItems, existingConversation) + } + + this.conversationMap.set(conversation.conversationId, conversation) + addedItems.push(conversation) + } + + return { addedItems, deletedItems } + } + + private async resolveMailSetEntries( + mailSetEntries: MailSetEntry[], + mailProvider: (listId: Id, elementIds: Id[]) => Promise, + ): Promise { + return resolveMailSetEntries(mailSetEntries, mailProvider, this.mailModel) + } + + private reverseSortConversation(item1: LoadedConversation, item2: LoadedConversation): number { + // Mail set entry ID has the timestamp and mail element ID + const item1Id = this.listModelIdOfConversation(item1) + const item2Id = this.listModelIdOfConversation(item2) + + // Sort in reverse order to ensure newer mails are first + return compare(customIdToUint8array(item2Id), customIdToUint8array(item1Id)) + } + + /** + * Load mails from the cache rather than remotely + */ + private async loadMailsFromCache(startId: IdTuple, count: number): Promise { + // The way the cache works is that it tries to fulfill the API contract of returning as many items as requested as long as it can. + // This is problematic for offline where we might not have the full page of emails loaded (e.g. we delete part as it's too old, or we move emails + // around). Because of that cache will try to load additional items from the server in order to return `count` items. If it fails to load them, + // it will not return anything and instead will throw an error. + // This is generally fine but in case of offline we want to display everything that we have cached. For that we fetch directly from the cache, + // give it to the list and let list make another request (and almost certainly fail that request) to show a retry button. This way we both show + // the items we have and also show that we couldn't load everything. + const mailSetEntries = await this.cacheStorage.provideFromRange(MailSetEntryTypeRef, listIdPart(startId), elementIdPart(startId), count, true) + return await this.resolveMailSetEntries(mailSetEntries, (list, elements) => this.cacheStorage.provideMultiple(MailTypeRef, list, elements)) + } + + private readonly defaultMailProvider = (listId: Id, elements: Id[]): Promise => { + return this.entityClient.loadMultiple(MailTypeRef, listId, elements) + } + + private readonly _items = memoizedWithHiddenArgument( + () => this.listModel.state.items, + (conversations) => conversations.map((conversation) => this.getLatestMailForConversation(conversation).mail), + ) + + private _mails = memoizedWithHiddenArgument( + () => this.mailMap, + (mailMap) => Array.from(mailMap.values()).map(({ mail }) => mail), + ) + + // @VisibleForTesting + _getMailMap(): ReadonlyMap { + return this.mailMap + } +} diff --git a/src/mail-app/mail/model/MailListModel.ts b/src/mail-app/mail/model/MailListModel.ts index bcc043dc0b28..227c3d273ae8 100644 --- a/src/mail-app/mail/model/MailListModel.ts +++ b/src/mail-app/mail/model/MailListModel.ts @@ -12,34 +12,24 @@ import { import { EntityClient } from "../../../common/api/common/EntityClient" import { ConversationPrefProvider } from "../view/ConversationViewModel" import { assertMainOrNode } from "../../../common/api/common/Env" -import { assertNotNull, compare, promiseFilter } from "@tutao/tutanota-utils" +import { assertNotNull, compare, first, last, memoizedWithHiddenArgument } from "@tutao/tutanota-utils" import { ListLoadingState, ListState } from "../../../common/gui/base/List" import Stream from "mithril/stream" import { EntityUpdateData, isUpdateForTypeRef } from "../../../common/api/common/utils/EntityUpdateUtils" -import { MailSetKind, OperationType } from "../../../common/api/common/TutanotaConstants" +import { OperationType } from "../../../common/api/common/TutanotaConstants" import { InboxRuleHandler } from "./InboxRuleHandler" import { MailModel } from "./MailModel" import { ListFetchResult } from "../../../common/gui/base/ListUtils" import { isOfflineError } from "../../../common/api/common/utils/ErrorUtils" import { ExposedCacheStorage } from "../../../common/api/worker/rest/DefaultEntityRestCache" +import { applyInboxRulesToEntries, LoadedMail, MailSetListModel, resolveMailSetEntries } from "./MailSetListModel" assertMainOrNode() -/** - * Internal representation of a loaded mail - * - * @VisibleForTesting - */ -export interface LoadedMail { - readonly mail: Mail - readonly mailSetEntry: MailSetEntry - readonly labels: ReadonlyArray -} - /** * Handles fetching and resolving mail set entries into mails as well as handling sorting. */ -export class MailListModel { +export class MailListModel implements MailSetListModel { // Id = MailSetEntry element id private readonly listModel: ListModel @@ -56,20 +46,20 @@ export class MailListModel { ) { this.listModel = new ListModel({ fetch: (lastFetchedItem, count) => { - const lastFetchedId = lastFetchedItem?.mailSetEntry?._id ?? [mailSet.entries, CUSTOM_MAX_ID] + const lastFetchedId = lastFetchedItem?.mailSetEntryId ?? [mailSet.entries, CUSTOM_MAX_ID] return this.loadMails(lastFetchedId, count) }, sortCompare: (item1, item2) => { // Mail set entry ID has the timestamp and mail element ID - const item1Id = getElementId(item1.mailSetEntry) - const item2Id = getElementId(item2.mailSetEntry) + const item1Id = elementIdPart(item1.mailSetEntryId) + const item2Id = elementIdPart(item2.mailSetEntryId) // Sort in reverse order to ensure newer mails are first return compare(customIdToUint8array(item2Id), customIdToUint8array(item1Id)) }, - getItemId: (item) => getElementId(item.mailSetEntry), + getItemId: (item) => elementIdPart(item.mailSetEntryId), isSameId: (id1, id2) => id1 === id2, @@ -77,8 +67,16 @@ export class MailListModel { }) } - get items(): Mail[] { - return this._loadedMails().map((mail) => mail.mail) + get items(): ReadonlyArray { + return this._items() + } + + get mails(): ReadonlyArray { + return this.items + } + + get lastItem(): Mail | null { + return last(this._loadedMails())?.mail ?? null } get loadingStatus(): ListLoadingState { @@ -87,15 +85,10 @@ export class MailListModel { get stateStream(): Stream> { return this.listModel.stateStream.map((state) => { - const items = state.items.map((item) => item.mail) - const selectedItems: Set = new Set() - for (const item of state.selectedItems) { - selectedItems.add(item.mail) - } const newState: ListState = { ...state, - items, - selectedItems, + items: this.items, + selectedItems: new Set(this.getSelectedAsArray()), } return newState }) @@ -110,7 +103,7 @@ export class MailListModel { if (loadedMail == null) { return false } - return this.listModel.isItemSelected(getElementId(loadedMail.mailSetEntry)) + return this.listModel.isItemSelected(elementIdPart(loadedMail.mailSetEntryId)) } getMail(mailElementId: Id): Mail | null { @@ -121,10 +114,6 @@ export class MailListModel { return this.getLoadedMailByMailInstance(mail)?.labels ?? [] } - getMailSetEntry(mailSetEntryId: Id): MailSetEntry | null { - return this.getLoadedMailByMailSetId(mailSetEntryId)?.mailSetEntry ?? null - } - async loadAndSelect(mailId: Id, shouldStop: () => boolean): Promise { const mailFinder = (loadedMail: LoadedMail) => isSameId(getElementId(loadedMail.mail), mailId) const mail = await this.listModel.loadAndSelect(mailFinder, shouldStop) @@ -147,9 +136,10 @@ export class MailListModel { await this.listModel.loadInitial() } - getSelectedAsArray(): Array { - return this.listModel.getSelectedAsArray().map(({ mail }) => mail) - } + readonly getSelectedAsArray = memoizedWithHiddenArgument( + () => this.listModel.getSelectedAsArray(), + (mails) => mails.map(({ mail }) => mail), + ) async handleEntityUpdate(update: EntityUpdateData) { if (isUpdateForTypeRef(MailFolderTypeRef, update)) { @@ -177,10 +167,10 @@ export class MailListModel { // Adding/removing to this list (MailSetEntry doesn't have any fields to update, so we don't need to handle this) if (update.operation === OperationType.DELETE) { const mail = this.getLoadedMailByMailSetId(update.instanceId) + await this.listModel.deleteLoadedItem(update.instanceId) if (mail) { this.mailMap.delete(getElementId(mail.mail)) } - await this.listModel.deleteLoadedItem(update.instanceId) } else if (update.operation === OperationType.CREATE) { const loadedMail = await this.loadSingleMail([update.instanceListId, update.instanceId]) await this.listModel.waitLoad(async () => { @@ -193,7 +183,7 @@ export class MailListModel { // We only need to handle updates for Mail. // Mail deletion will also be handled in MailSetEntry delete/create. const mailItem = this.mailMap.get(update.instanceId) - if (mailItem != null && update.operation === OperationType.UPDATE) { + if (mailItem != null && (update.operation === OperationType.UPDATE || update.operation === OperationType.CREATE)) { const newMailData = await this.entityClient.load(MailTypeRef, [update.instanceListId, update.instanceId]) const labels = this.mailModel.getLabelsForMail(newMailData) // in case labels were added/removed const newMailItem = { @@ -266,6 +256,14 @@ export class MailListModel { this.listModel.stopLoading() } + getDisplayedMail(): Mail | null { + if (this.isInMultiselect()) { + return null + } else { + return first(this.getSelectedAsArray()) + } + } + private getLoadedMailByMailId(mailId: Id): LoadedMail | null { return this.mailMap.get(mailId) ?? null } @@ -337,17 +335,7 @@ export class MailListModel { * Apply inbox rules to an array of mails, returning all mails that were not moved */ private async applyInboxRulesToEntries(entries: LoadedMail[]): Promise { - if (this.mailSet.folderType !== MailSetKind.INBOX || entries.length === 0) { - return entries - } - const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(this.mailSet) - if (!mailboxDetail) { - return entries - } - return await promiseFilter(entries, async (entry) => { - const ruleApplied = await this.inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, entry.mail, true) - return ruleApplied == null - }) + return applyInboxRulesToEntries(entries, this.mailSet, this.mailModel, this.inboxRuleHandler) } private async loadSingleMail(id: IdTuple): Promise { @@ -357,51 +345,11 @@ export class MailListModel { return assertNotNull(loadedMails[0]) } - /** - * Loads all Mail instances for each MailSetEntry, returning a tuple of each - */ private async resolveMailSetEntries( mailSetEntries: MailSetEntry[], mailProvider: (listId: Id, elementIds: Id[]) => Promise, ): Promise { - // Sort all mails into mailbags so we can retrieve them with loadMultiple - const mailListMap: Map = new Map() - for (const entry of mailSetEntries) { - const mailBag = listIdPart(entry.mail) - const mailElementId = elementIdPart(entry.mail) - let mailIds = mailListMap.get(mailBag) - if (!mailIds) { - mailIds = [] - mailListMap.set(mailBag, mailIds) - } - mailIds.push(mailElementId) - } - - // Retrieve all mails by mailbag - const allMails: Map = new Map() - for (const [list, elements] of mailListMap) { - const mails = await mailProvider(list, elements) - for (const mail of mails) { - allMails.set(getElementId(mail), mail) - } - } - - // Build our array - const loadedMails: LoadedMail[] = [] - for (const mailSetEntry of mailSetEntries) { - const mail = allMails.get(elementIdPart(mailSetEntry.mail)) - - // Mail may have been deleted in the meantime - if (!mail) { - continue - } - - // Resolve labels - const labels: MailFolder[] = this.mailModel.getLabelsForMail(mail) - loadedMails.push({ mailSetEntry, mail, labels }) - } - - return loadedMails + return resolveMailSetEntries(mailSetEntries, mailProvider, this.mailModel) } private updateMailMap(mails: LoadedMail[]) { @@ -424,4 +372,9 @@ export class MailListModel { private readonly defaultMailProvider = (listId: Id, elements: Id[]): Promise => { return this.entityClient.loadMultiple(MailTypeRef, listId, elements) } + + private readonly _items = memoizedWithHiddenArgument( + () => this.listModel.state.items, + (mails: ReadonlyArray) => mails.map((mail) => mail.mail), + ) } diff --git a/src/mail-app/mail/model/MailSetListModel.ts b/src/mail-app/mail/model/MailSetListModel.ts new file mode 100644 index 000000000000..a0f231db237a --- /dev/null +++ b/src/mail-app/mail/model/MailSetListModel.ts @@ -0,0 +1,296 @@ +import { Mail, MailFolder, MailSetEntry } from "../../../common/api/entities/tutanota/TypeRefs" +import { ListFilter } from "../../../common/misc/ListModel" +import { ListLoadingState, ListState } from "../../../common/gui/base/List" +import { EntityUpdateData } from "../../../common/api/common/utils/EntityUpdateUtils" +import Stream from "mithril/stream" +import { MailModel } from "./MailModel" +import { elementIdPart, getElementId, listIdPart } from "../../../common/api/common/utils/EntityUtils" +import { MailSetKind } from "../../../common/api/common/TutanotaConstants" +import { groupByAndMap, promiseFilter } from "@tutao/tutanota-utils" +import { InboxRuleHandler } from "./InboxRuleHandler" + +/** + * Interface for retrieving and listing mails + */ +export interface MailSetListModel { + /** + * Get all Mail instances displayed in the list model. + * + * This list is sorted by MailSetEntry ID. + * + * Depending on implementation, this may or may not be the same thing as calling `this.mails` and may even return + * the exact same array. However, this behavior should not be relied upon. You should only call this method if you + * want all *displayed* mails, where `this.mails` should be used for getting all loaded mails regardless of if they + * are displayed. + * + * If the items have not changed, then subsequent calls will return the same array instance. + */ + get items(): ReadonlyArray + + /** + * @return the oldest mail displayed in the list model + */ + get lastItem(): Mail | null + + /** + * Get all loaded Mail instances. + * + * Unlike `items`, the ordering of the array is implementation-defined and should not be relied upon. + * + * Additionally, it may return more unique Mail instances than what is actually listed (but never less). + * + * See the `items` getter for the difference between `mails` and `items`. + * + * If the items have not changed, then subsequent calls will return the same array instance. + */ + get mails(): ReadonlyArray + + /** + * @return a state stream for subscribing to list updates + */ + get stateStream(): Stream> + + /** + * @return the current loading state + */ + get loadingStatus(): ListLoadingState + + /** + * @return true if in multiselect mode + */ + isInMultiselect(): boolean + + /** + * @return true if the mail is selected + */ + isItemSelected(mailId: Id): boolean + + /** + * @return true if the list is empty and isn't loading anything (basically, the list is known to contain no mails) + */ + isEmptyAndDone(): boolean + + /** + * Enter multiselect mode + */ + enterMultiselect(): void + + /** + * Begin loading the list + */ + loadInitial(): Promise + + /** + * Get all selected items. + * + * If the items have not changed, then subsequent calls will return the same array. + */ + getSelectedAsArray(): Mail[] + + /** + * Set the filter + * @param filterType filter type to use + */ + setFilter(filterType: ListFilter | null): void + + /** + * Abort loading. No-op if not loading. + */ + stopLoading(): void + + /** + * Retry loading. + */ + retryLoading(): Promise + + /** + * Load older items in the list. + */ + loadMore(): Promise + + /** + * Deselect all items. + */ + selectNone(): void + + /** + * Select all loaded items. + */ + selectAll(): void + + /** + * @return true if all items are selected (i.e. selectAll would do nothing) + */ + areAllSelected(): boolean + + /** + * Get the mail if it is loaded + * @param mailId + */ + getMail(mailId: Id): Mail | null + + /** + * Handle entity events + * @param update + */ + handleEntityUpdate(update: EntityUpdateData): Promise + + /** + * Select the item in the list + * @param mail + */ + onSingleSelection(mail: Mail): void + + /** + * Attempt to load the mail in the list and select it + * @param mailId + * @param shouldStop + */ + loadAndSelect(mailId: Id, shouldStop: () => boolean): Promise + + /** + * Multi-select, add the mail to the selection + * @param mail + * @param clearSelectionOnMultiSelectStart + */ + onSingleInclusiveSelection(mail: Mail, clearSelectionOnMultiSelectStart?: boolean): void + + /** + * Deselect any other item if not in multi-select mode and select the given mail. + * @param mail + */ + onSingleExclusiveSelection(mail: Mail): void + + /** + * Select all mails from the current selection towards this mail. + * @param mail + */ + selectRangeTowards(mail: Mail): void + + /** + * Select the previous mail in the list from what is already selected. + * @param multiSelect + */ + selectPrevious(multiSelect: boolean): void + + /** + * Select the next mail in the list from what is already selected. + * @param multiSelect + */ + selectNext(multiSelect: boolean): void + + /** + * Get all labels for the mail. + * @param mail + */ + getLabelsForMail(mail: Mail): ReadonlyArray + + /** + * Load the entire list. + */ + loadAll(): Promise + + /** + * Cancel loading the entire list. + */ + cancelLoadAll(): void + + /** + * @return true if the entire list is being loaded + */ + isLoadingAll(): boolean + + /** + * Get the mail to display. + * + * This may not correspond to the current selection (if a mail was directly selected via URL), and in multiselect + * mode, this may not return anything. + */ + getDisplayedMail(): Mail | null +} + +/** + * Internal representation of a loaded mail + */ +export interface LoadedMail { + readonly mail: Mail + readonly mailSetEntryId: IdTuple + readonly labels: ReadonlyArray +} + +/** + * Loads all Mail instances for each MailSetEntry, returning a tuple of each + */ +export async function resolveMailSetEntries( + mailSetEntries: MailSetEntry[], + mailProvider: (listId: Id, elementIds: Id[]) => Promise, + mailModel: MailModel, +): Promise { + // Retrieve all mails by mailbag + const downloadedMails = await provideAllMails( + mailSetEntries.map((entry) => entry.mail), + mailProvider, + ) + const allMails: Map = new Map() + for (const mail of downloadedMails) { + allMails.set(getElementId(mail), mail) + } + + // Build our array + const loadedMails: LoadedMail[] = [] + for (const mailSetEntry of mailSetEntries) { + const mail = allMails.get(elementIdPart(mailSetEntry.mail)) + + // Mail may have been deleted in the meantime + if (!mail) { + continue + } + + // Resolve labels + const labels: MailFolder[] = mailModel.getLabelsForMail(mail) + loadedMails.push({ mailSetEntryId: mailSetEntry._id, mail, labels }) + } + + return loadedMails +} + +/** + * Retrieve all mails in as few requests as possible + * @param ids mails to obtain + * @param mailProvider mail provider to use that gets mails + */ +export async function provideAllMails(ids: IdTuple[], mailProvider: (listId: Id, elementIds: Id[]) => Promise): Promise { + // MailBag -> Mail element ID + const mailListMap: Map = groupByAndMap(ids, listIdPart, elementIdPart) + + // Retrieve all mails by mailbag + const allMails: Mail[] = [] + for (const [list, elements] of mailListMap) { + const mails = await mailProvider(list, elements) + allMails.push(...mails) + } + + return allMails +} + +/** + * Apply inbox rules to an array of mails, returning all mails that were not moved + */ +export async function applyInboxRulesToEntries( + entries: LoadedMail[], + mailSet: MailFolder, + mailModel: MailModel, + inboxRuleHandler: InboxRuleHandler, +): Promise { + if (mailSet.folderType !== MailSetKind.INBOX || entries.length === 0) { + return entries + } + const mailboxDetail = await mailModel.getMailboxDetailsForMailFolder(mailSet) + if (!mailboxDetail) { + return entries + } + return await promiseFilter(entries, async (entry) => { + const ruleApplied = await inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, entry.mail, true) + return ruleApplied == null + }) +} diff --git a/src/mail-app/mail/view/MailListView.ts b/src/mail-app/mail/view/MailListView.ts index 02468ca68489..a4eb5a6bca52 100644 --- a/src/mail-app/mail/view/MailListView.ts +++ b/src/mail-app/mail/view/MailListView.ts @@ -336,7 +336,7 @@ export class MailListView implements Component { this._listDom?.classList.remove("drag-mod-key") } - const listModel = vnode.attrs.mailViewModel.listModel! + const listModel = vnode.attrs.mailViewModel.listModel return m( ".mail-list-wrapper", { @@ -362,7 +362,7 @@ export class MailListView implements Component { { headerContent: this.renderListHeader(purgeButtonAttrs), }, - listModel.isEmptyAndDone() + listModel == null || listModel.isEmptyAndDone() ? m(ColumnEmptyMessageBox, { icon: BootIcons.Mail, message: "noMails_msg", diff --git a/src/mail-app/mail/view/MailViewModel.ts b/src/mail-app/mail/view/MailViewModel.ts index 676a857fb990..5bad9d547f0b 100644 --- a/src/mail-app/mail/view/MailViewModel.ts +++ b/src/mail-app/mail/view/MailViewModel.ts @@ -18,21 +18,7 @@ import { isSameId, listIdPart, } from "../../../common/api/common/utils/EntityUtils.js" -import { - assertNotNull, - count, - debounce, - first, - groupBy, - isNotEmpty, - lastThrow, - lazyMemoized, - mapWith, - mapWithout, - memoized, - ofClass, - promiseMap, -} from "@tutao/tutanota-utils" +import { assertNotNull, count, debounce, groupBy, isNotEmpty, lazyMemoized, mapWith, mapWithout, ofClass, promiseMap } from "@tutao/tutanota-utils" import { ListState } from "../../../common/gui/base/List.js" import { ConversationPrefProvider, ConversationViewModel, ConversationViewModelFactory } from "./ConversationViewModel.js" import { CreateMailViewerOptions } from "./MailViewer.js" @@ -55,6 +41,9 @@ import { getMailFilterForType, MailFilterType } from "./MailViewerUtils.js" import { CacheMode } from "../../../common/api/worker/rest/EntityRestClient.js" import { isOfTypeOrSubfolderOf, isSpamOrTrashFolder, isSubfolderOfType } from "../model/MailChecks.js" import { MailListModel } from "../model/MailListModel" +import { MailSetListModel } from "../model/MailSetListModel" +import { ConversationListModel } from "../model/ConversationListModel" +import { MailListDisplayMode } from "../../../common/misc/DeviceConfig" export interface MailOpenedListener { onEmailOpened(mail: Mail): unknown @@ -62,9 +51,20 @@ export interface MailOpenedListener { const TAG = "MailVM" +/** + * These folders will always use the mail list model instead of the conversation list model regardless of the user's + * settings. + */ +const MAIL_LIST_FOLDERS: MailSetKind[] = [MailSetKind.DRAFT, MailSetKind.SENT] + +export interface MailListViewPrefProvider { + getMailListDisplayMode(): MailListDisplayMode +} + /** ViewModel for the overall mail view. */ export class MailViewModel { private _folder: MailFolder | null = null + private _listModel: MailSetListModel | null = null /** id of the mail that was requested to be displayed, independent of the list state. */ private stickyMailId: IdTuple | null = null /** @@ -82,6 +82,7 @@ export class MailViewModel { private mailFolderElementIdToSelectedMailId: ReadonlyMap = new Map() private listStreamSubscription: Stream | null = null private conversationPref: boolean = false + private groupMailsByConversationPref: boolean = false /** A slightly hacky marker to avoid concurrent URL updates. */ private currentShowTargetMarker: object = {} @@ -98,6 +99,7 @@ export class MailViewModel { private readonly inboxRuleHandler: InboxRuleHandler, private readonly router: Router, private readonly updateUi: () => unknown, + private readonly mailListViewPrefProvider: MailListViewPrefProvider, ) {} getSelectedMailSetKind(): MailSetKind | null { @@ -320,7 +322,7 @@ export class MailViewModel { // if the target mail has changed, stop this.loadingTargetId !== mailId || // if we loaded past the target item we won't find it, stop - (this.listModel.items.length > 0 && firstBiggerThanSecond(mailId, getElementId(lastThrow(this.listModel.items)))), + (this.listModel.lastItem != null && firstBiggerThanSecond(mailId, getElementId(this.listModel.lastItem))), ) if (foundMail == null) { console.log("did not find mail", folder, mailId) @@ -333,10 +335,12 @@ export class MailViewModel { return assertSystemFolderOfType(folders, MailSetKind.INBOX) } + /** init is called every time the view is opened */ init() { - this.singInit() - const conversationEnabled = this.conversationPrefProvider.getConversationViewShowOnlySelectedMail() - if (this.conversationViewModel && this.conversationPref !== conversationEnabled) { + this.onceInit() + const conversationDisabled = this.conversationPrefProvider.getConversationViewShowOnlySelectedMail() + const mailListViewPref = this.mailListViewPrefProvider.getMailListDisplayMode() === MailListDisplayMode.CONVERSATIONS && !conversationDisabled + if (this.conversationViewModel && this.conversationPref !== conversationDisabled) { const mail = this.conversationViewModel.primaryMail this.createConversationViewModel({ mail, @@ -345,15 +349,23 @@ export class MailViewModel { }) this.mailOpenedListener.onEmailOpened(mail) } - this.conversationPref = conversationEnabled + + this.conversationPref = conversationDisabled + + const oldGroupMailsByConversationPref = this.groupMailsByConversationPref + this.groupMailsByConversationPref = mailListViewPref + if (oldGroupMailsByConversationPref !== mailListViewPref) { + // if the preference for conversation in list has changed we need to re-create the list model + this.updateListModel() + } } - private readonly singInit = lazyMemoized(() => { + private readonly onceInit = lazyMemoized(() => { this.eventController.addEntityListener((updates) => this.entityEventsReceived(updates)) }) - get listModel(): MailListModel | null { - return this._folder ? this.listModelForFolder(getElementId(this._folder)) : null + get listModel(): MailSetListModel | null { + return this._listModel } getMailFolderToSelectedMail(): ReadonlyMap { @@ -369,37 +381,71 @@ export class MailViewModel { } private setListId(folder: MailFolder) { - if (folder === this._folder) { - return - } - // Cancel old load all - this.listModel?.cancelLoadAll() - this._filterType = null - + const oldFolderId = this._folder?._id + // update folder just in case, maybe it got updated this._folder = folder - this.listStreamSubscription?.end(true) - this.listStreamSubscription = this.listModel!.stateStream.map((state) => this.onListStateChange(state)) - this.listModel!.loadInitial().then(() => { - if (this.listModel != null && this._folder === folder) { - this.fixCounterIfNeeded(folder, this.listModel.items) - } - }) + + // only re-create list things if it's actually another folder + if (!oldFolderId || !isSameId(oldFolderId, folder._id)) { + // Cancel old load all + this.listModel?.cancelLoadAll() + this._filterType = null + + // the open folder has changed which means we need another list model with data for this list + this.updateListModel() + } } getConversationViewModel(): ConversationViewModel | null { return this.conversationViewModel } - private listModelForFolder = memoized((_folderId: Id) => { - // Capture state to avoid race conditions. - // We need to populate mail set entries cache when loading mails so that we can react to updates later. - const folder = assertNotNull(this._folder) - return new MailListModel(folder, this.conversationPrefProvider, this.entityClient, this.mailModel, this.inboxRuleHandler, this.cacheStorage) - }) + // deinit old list model if it exists and create and init a new one + private updateListModel() { + if (this._folder == null) { + this.listStreamSubscription?.end(true) + this.listStreamSubscription = null + this._listModel = null + } else { + // Capture state to avoid race conditions. + // We need to populate mail set entries cache when loading mails so that we can react to updates later. + const folder = this._folder + + let listModel: MailSetListModel + if (this.groupMailsByConversationPref && !this.folderNeverGroupsMails(folder)) { + listModel = new ConversationListModel( + folder, + this.conversationPrefProvider, + this.entityClient, + this.mailModel, + this.inboxRuleHandler, + this.cacheStorage, + ) + } else { + listModel = new MailListModel( + folder, + this.conversationPrefProvider, + this.entityClient, + this.mailModel, + this.inboxRuleHandler, + this.cacheStorage, + ) + } + this.listStreamSubscription?.end(true) + this.listStreamSubscription = listModel.stateStream.map((state: ListState) => this.onListStateChange(listModel, state)) + listModel.loadInitial().then(() => { + if (this.listModel != null && this._folder === folder) { + this.fixCounterIfNeeded(folder, this.listModel.mails) + } + }) - private fixCounterIfNeeded: (folder: MailFolder, itemsWhenCalled: ReadonlyArray) => void = debounce( + this._listModel = listModel + } + } + + private fixCounterIfNeeded: (folder: MailFolder, loadedMailsWhenCalled: ReadonlyArray) => void = debounce( 2000, - async (folder: MailFolder, itemsWhenCalled: ReadonlyArray) => { + async (folder: MailFolder, loadedMailsWhenCalled: ReadonlyArray) => { const ourFolder = this.getFolder() if (ourFolder == null || (this._filterType != null && this.filterType !== MailFilterType.Unread)) { return @@ -412,12 +458,12 @@ export class MailViewModel { } // If list was modified in the meantime, we cannot be sure that we will fix counters correctly (e.g. because of the inbox rules) - if (this.listModel?.items !== itemsWhenCalled) { + if (this.listModel?.mails !== loadedMailsWhenCalled) { console.log(`list changed, trying again later`) - return this.fixCounterIfNeeded(folder, this.listModel?.items ?? []) + return this.fixCounterIfNeeded(folder, this.listModel?.mails ?? []) } - const unreadMailsCount = count(this.listModel.items, (e) => e.unread) + const unreadMailsCount = count(this.listModel.mails, (e) => e.unread) const counterValue = await this.mailModel.getCounterValue(folder) if (counterValue != null && counterValue !== unreadMailsCount) { @@ -429,16 +475,12 @@ export class MailViewModel { }, ) - private onListStateChange(newState: ListState) { + private onListStateChange(listModel: MailSetListModel, newState: ListState) { // If we are already displaying sticky mail just leave it alone, no matter what's happening to the list. // User actions and URL updated do reset sticky mail id. const displayedMailId = this.conversationViewModel?.primaryViewModel()?.mail._id if (!(displayedMailId && isSameId(displayedMailId, this.stickyMailId))) { - const targetItem = this.stickyMailId - ? newState.items.find((item) => isSameId(this.stickyMailId, item._id)) - : !newState.inMultiselect && newState.selectedItems.size === 1 - ? first(this.listModel!.getSelectedAsArray()) - : null + const targetItem = this.stickyMailId ? newState.items.find((item) => isSameId(this.stickyMailId, item._id)) : listModel.getDisplayedMail() if (targetItem != null) { // Always write the targetItem in case it was not written before but already being displayed (sticky mail) this.mailFolderElementIdToSelectedMailId = mapWith( @@ -530,14 +572,15 @@ export class MailViewModel { private async processImportedMails(update: EntityUpdateData) { const importMailState = await this.entityClient.load(ImportMailStateTypeRef, [update.instanceListId, update.instanceId]) - const listModelOfImport = this.listModelForFolder(elementIdPart(importMailState.targetFolder)) - let status = parseInt(importMailState.status) as ImportStatus if (status === ImportStatus.Finished || status === ImportStatus.Canceled) { - let importedMailEntries = await this.entityClient.loadAll(ImportedMailTypeRef, importMailState.importedMails) - if (importedMailEntries.length === 0) return Promise.resolve() + const importedMailEntries = await this.entityClient.loadAll(ImportedMailTypeRef, importMailState.importedMails) + if (importedMailEntries.length === 0 || this._folder == null || !isSameId(this._folder._id, importMailState.targetFolder)) { + return + } + const listModelOfImport = assertNotNull(this._listModel) - let mailSetEntryIds = importedMailEntries.map((importedMail) => elementIdPart(importedMail.mailSetEntry)) + const mailSetEntryIds = importedMailEntries.map((importedMail) => elementIdPart(importedMail.mailSetEntry)) const mailSetEntryListId = listIdPart(importedMailEntries[0].mailSetEntry) const importedMailSetEntries = await this.entityClient.loadMultiple(MailSetEntryTypeRef, mailSetEntryListId, mailSetEntryIds) if (isNotEmpty(importedMailSetEntries)) { @@ -708,4 +751,8 @@ export class MailViewModel { async deleteLabel(label: MailFolder) { await this.mailModel.deleteLabel(label) } + + private folderNeverGroupsMails(mailSet: MailFolder): boolean { + return MAIL_LIST_FOLDERS.includes(mailSet.folderType as MailSetKind) + } } diff --git a/src/mail-app/mailLocator.ts b/src/mail-app/mailLocator.ts index 1f4186c57f64..8e088287e7cf 100644 --- a/src/mail-app/mailLocator.ts +++ b/src/mail-app/mailLocator.ts @@ -253,6 +253,7 @@ class MailLocator { this.inboxRuleHanlder(), router, await this.redraw(), + deviceConfig, ) }) diff --git a/src/mail-app/settings/MailSettingsViewer.ts b/src/mail-app/settings/MailSettingsViewer.ts index 889a01ac121c..016888552b17 100644 --- a/src/mail-app/settings/MailSettingsViewer.ts +++ b/src/mail-app/settings/MailSettingsViewer.ts @@ -35,7 +35,7 @@ import { formatActivateState, loadOutOfOfficeNotification } from "../../common/m import { getSignatureType, show as showEditSignatureDialog } from "./EditSignatureDialog" import { OfflineStorageSettingsModel } from "./OfflineStorageSettings" import { showNotAvailableForFreeDialog } from "../../common/misc/SubscriptionDialogs" -import { deviceConfig, ListAutoSelectBehavior } from "../../common/misc/DeviceConfig" +import { deviceConfig, ListAutoSelectBehavior, MailListDisplayMode } from "../../common/misc/DeviceConfig" import { IconButton, IconButtonAttrs } from "../../common/gui/base/IconButton.js" import { ButtonSize } from "../../common/gui/base/ButtonSize.js" import { getReportMovedMailsType } from "../../common/misc/MailboxPropertiesUtils.js" @@ -307,6 +307,20 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { }, dropdownWidth: 350, } + const mailListView: DropDownSelectorAttrs = { + label: "mailListGrouping_label", + // Don't group means normal view instead of conversation + items: [ + { name: lang.get("mailListGroupingDontGroup_label"), value: MailListDisplayMode.MAILS }, + { name: lang.get("mailListGroupingGroupByConversation_label"), value: MailListDisplayMode.CONVERSATIONS }, + ], + selectedValue: deviceConfig.getMailListDisplayMode(), + helpLabel: () => lang.get("mailListGroupingHelp_msg"), + selectionChangedHandler: (arg: MailListDisplayMode) => { + deviceConfig.setMailListDisplayMode(arg) + }, + dropdownWidth: 350, + } return [ m( "#user-settings.fill-absolute.scroll.plr-l.pb-xl", @@ -339,6 +353,7 @@ export class MailSettingsViewer implements UpdatableSettingsViewer { : null, m(".h4.mt-l", lang.get("general_label")), m(DropDownSelector, conversationViewDropdownAttrs), + m(DropDownSelector, mailListView), m(DropDownSelector, enableMailIndexingAttrs), m(DropDownSelector, behaviorAfterMoveEmailAction), m(".h4.mt-l", lang.get("emailSending_label")), diff --git a/src/mail-app/translations/de.ts b/src/mail-app/translations/de.ts index e22af8128520..e43565ad9403 100644 --- a/src/mail-app/translations/de.ts +++ b/src/mail-app/translations/de.ts @@ -1909,9 +1909,13 @@ export default { "yourFolders_action": "DEINE ORDNER", "yourMessage_label": "Deine Nachricht", "you_label": "Du", - // Put in temporarily, will be removed soon - "localAdminGroup_label": "Local admin group", - "assignAdminRightsToLocallyAdministratedUserError_msg": "You can't assign global admin rights to a locally administrated user.", - "localAdminGroups_label": "Local admin groups" + "mailListGrouping_label": "Email list grouping", + "mailListGroupingDontGroup_label": "Don't group", + "mailListGroupingGroupByConversation_label": "Group by conversation", + "mailListGroupingHelp_msg": "Group emails in a folder by conversation, or list each email separately.", + // Put in temporarily, will be removed soon + "localAdminGroup_label": "Local admin group", + "assignAdminRightsToLocallyAdministratedUserError_msg": "You can't assign global admin rights to a locally administrated user.", + "localAdminGroups_label": "Local admin groups" } } diff --git a/src/mail-app/translations/de_sie.ts b/src/mail-app/translations/de_sie.ts index f8fd7fec2f6a..43e5addbffb4 100644 --- a/src/mail-app/translations/de_sie.ts +++ b/src/mail-app/translations/de_sie.ts @@ -1909,9 +1909,13 @@ export default { "yourFolders_action": "Ihre ORDNER", "yourMessage_label": "Ihre Nachricht", "you_label": "Sie", - // Put in temporarily, will be removed soon - "localAdminGroup_label": "Local admin group", - "assignAdminRightsToLocallyAdministratedUserError_msg": "You can't assign global admin rights to a locally administrated user.", - "localAdminGroups_label": "Local admin groups", - } + "mailListGrouping_label": "Email list grouping", + "mailListGroupingDontGroup_label": "Don't group", + "mailListGroupingGroupByConversation_label": "Group by conversation", + "mailListGroupingHelp_msg": "Group emails in a folder by conversation, or list each email separately.", + // Put in temporarily, will be removed soon + "localAdminGroup_label": "Local admin group", + "assignAdminRightsToLocallyAdministratedUserError_msg": "You can't assign global admin rights to a locally administrated user.", + "localAdminGroups_label": "Local admin groups", + } } diff --git a/src/mail-app/translations/en.ts b/src/mail-app/translations/en.ts index 0e8e115f8e3d..3b647e4d62df 100644 --- a/src/mail-app/translations/en.ts +++ b/src/mail-app/translations/en.ts @@ -1905,9 +1905,13 @@ export default { "yourFolders_action": "YOUR FOLDERS", "yourMessage_label": "Your message", "you_label": "You", - // Put in temporarily, will be removed soon - "localAdminGroup_label": "Local admin group", - "assignAdminRightsToLocallyAdministratedUserError_msg": "You can't assign global admin rights to a locally administrated user.", - "localAdminGroups_label": "Local admin groups" + "mailListGrouping_label": "Email list grouping", + "mailListGroupingDontGroup_label": "Don't group", + "mailListGroupingGroupByConversation_label": "Group by conversation", + "mailListGroupingHelp_msg": "Group emails in a folder by conversation, or list each email separately.", + // Put in temporarily, will be removed soon + "localAdminGroup_label": "Local admin group", + "assignAdminRightsToLocallyAdministratedUserError_msg": "You can't assign global admin rights to a locally administrated user.", + "localAdminGroups_label": "Local admin groups" } } diff --git a/test/tests/Suite.ts b/test/tests/Suite.ts index 218e156a8bbc..a564548f806b 100644 --- a/test/tests/Suite.ts +++ b/test/tests/Suite.ts @@ -118,6 +118,7 @@ import "./misc/RecipientsModelTest.js" import "./api/worker/facades/MailAddressFacadeTest.js" import "./mail/model/FolderSystemTest.js" import "./mail/model/MailListModelTest.js" +import "./mail/model/ConversationListModelTest.js" import "./gui/ScopedRouterTest.js" import "./contacts/ContactListEditorTest.js" import "./login/PostLoginUtilsTest.js" diff --git a/test/tests/mail/model/ConversationListModelTest.ts b/test/tests/mail/model/ConversationListModelTest.ts new file mode 100644 index 000000000000..b0bdbf9bb9c9 --- /dev/null +++ b/test/tests/mail/model/ConversationListModelTest.ts @@ -0,0 +1,636 @@ +import o from "../../../../packages/otest/dist/otest" +import { + createMailSetEntry, + Mail, + MailboxGroupRootTypeRef, + MailBoxTypeRef, + MailFolder, + MailFolderTypeRef, + MailSetEntry, + MailSetEntryTypeRef, + MailTypeRef, +} from "../../../../src/common/api/entities/tutanota/TypeRefs" +import { matchers, object, verify, when } from "testdouble" +import { ConversationPrefProvider } from "../../../../src/mail-app/mail/view/ConversationViewModel" +import { EntityClient } from "../../../../src/common/api/common/EntityClient" +import { MailModel } from "../../../../src/mail-app/mail/model/MailModel" +import { InboxRuleHandler } from "../../../../src/mail-app/mail/model/InboxRuleHandler" +import { ExposedCacheStorage } from "../../../../src/common/api/worker/rest/DefaultEntityRestCache" +import { MailSetKind, OperationType } from "../../../../src/common/api/common/TutanotaConstants" +import { + constructMailSetEntryId, + CUSTOM_MAX_ID, + CUSTOM_MIN_ID, + deconstructMailSetEntryId, + elementIdPart, + GENERATED_MAX_ID, + getElementId, + getListId, + isSameId, + listIdPart, +} from "../../../../src/common/api/common/utils/EntityUtils" +import { PageSize } from "../../../../src/common/gui/base/ListUtils" +import { createTestEntity } from "../../TestUtils" +import { tutaDunkel, tutaRed } from "../../../../src/common/gui/builtinThemes" +import { EntityUpdateData } from "../../../../src/common/api/common/utils/EntityUpdateUtils" +import { MailboxDetail } from "../../../../src/common/mailFunctionality/MailboxModel" +import { GroupInfoTypeRef, GroupTypeRef } from "../../../../src/common/api/entities/sys/TypeRefs" +import { ConnectionError } from "../../../../src/common/api/common/error/RestError" +import { clamp, pad } from "@tutao/tutanota-utils" +import { LoadedMail } from "../../../../src/mail-app/mail/model/MailSetListModel" +import { ConversationListModel } from "../../../../src/mail-app/mail/model/ConversationListModel" + +o.spec("ConversationListModelTest", () => { + let model: ConversationListModel + + const mailboxDetail: MailboxDetail = { + mailbox: createTestEntity(MailBoxTypeRef), + mailGroupInfo: createTestEntity(GroupInfoTypeRef), + mailGroup: createTestEntity(GroupTypeRef), + mailboxGroupRoot: createTestEntity(MailboxGroupRootTypeRef), + } + + const mailSetEntriesListId = "entries" + const _ownerGroup = "me" + + const labels: MailFolder[] = [ + createTestEntity(MailFolderTypeRef, { + _id: ["mailFolderList", "tutaRed"], + color: tutaRed, + folderType: MailSetKind.LABEL, + isMailSet: true, + name: "Tuta Red Label", + parentFolder: null, + }), + createTestEntity(MailFolderTypeRef, { + _id: ["mailFolderList", "tutaDunkel"], + color: tutaDunkel, + folderType: MailSetKind.LABEL, + isMailSet: true, + name: "Tuta Dunkel Label", + parentFolder: null, + }), + ] + + let mailSet: MailFolder + let conversationPrefProvider: ConversationPrefProvider + let entityClient: EntityClient + let mailModel: MailModel + let inboxRuleHandler: InboxRuleHandler + let cacheStorage: ExposedCacheStorage + + o.beforeEach(() => { + mailSet = createTestEntity(MailFolderTypeRef, { + _id: ["mailFolderList", "mailFolderId"], + folderType: MailSetKind.CUSTOM, + isMailSet: true, + name: "My Folder", + entries: mailSetEntriesListId, + parentFolder: null, + }) + + conversationPrefProvider = object() + entityClient = object() + mailModel = object() + inboxRuleHandler = object() + cacheStorage = object() + model = new ConversationListModel(mailSet, conversationPrefProvider, entityClient, mailModel, inboxRuleHandler, cacheStorage) + when(mailModel.getMailboxDetailsForMailFolder(mailSet)).thenResolve(mailboxDetail) + }) + + // Care has to be ensured for generating mail set entry IDs as we depend on real mail set ID decoding, thus we have + // some helper methods for generating IDs for these tests. + function makeMailId(index: number): IdTuple { + const mailBag = index % 10 + return [`${mailBag}`, pad(index, GENERATED_MAX_ID.length)] + } + + function makeMailSetElementId(index: number): Id { + return constructMailSetEntryId(new Date(index * 100), elementIdPart(makeMailId(index))) + } + + function mailSetElementIdToIndex(mailSetElementId: Id): number { + return Number(deconstructMailSetEntryId(mailSetElementId).mailId) + } + + async function setUpTestData(count: number, initialLabels: MailFolder[], offline: boolean, mailsPerConversation: number) { + const mailSetEntries: MailSetEntry[] = [] + const mails: Mail[][] = [[], [], [], [], [], [], [], [], [], []] + + for (let i = 0; i < count; i++) { + const mailBag = i % 10 + const mailId: IdTuple = makeMailId(i) + const conversationId = "" + Math.floor(i / mailsPerConversation) + + const mail = createTestEntity(MailTypeRef, { + _id: mailId, + sets: [mailSet._id, ...initialLabels.map((l) => l._id)], + conversationEntry: [conversationId, elementIdPart(mailId)], + }) + + mails[mailBag].push(mail) + + mailSetEntries.push( + createMailSetEntry({ + _id: [mailSetEntriesListId, makeMailSetElementId(i)], + _ownerGroup, + _permissions: "1234", + mail: mailId, + }), + ) + } + + when(mailModel.getLabelsForMail(matchers.anything())).thenDo((mail: Mail) => { + const sets: MailFolder[] = [] + for (const set of mail.sets) { + const setToAdd = labels.find((label) => isSameId(label._id, set)) + if (setToAdd) { + sets.push(setToAdd) + } + } + return sets + }) + + // Ensures elements are loaded from the array in reverse order + async function getMailSetEntryMock(_mailSetEntry: any, _listId: Id, startingId: Id, count: number, _reverse: boolean): Promise { + let endingIndex: number + if (startingId === CUSTOM_MAX_ID) { + endingIndex = mailSetEntries.length + } else { + endingIndex = mailSetElementIdToIndex(startingId) + } + endingIndex = clamp(endingIndex, 0, mailSetEntries.length) + + const startingIndex = clamp(endingIndex - count, 0, endingIndex) + return mailSetEntries.slice(startingIndex, endingIndex).reverse() + } + + async function getMailsMock(_mailTypeRef: any, mailBag: string, elements: Id[]): Promise { + const mailsInMailBag = mails[Number(mailBag)] ?? [] + return mailsInMailBag.filter((mail) => elements.includes(getElementId(mail))) + } + + when(cacheStorage.provideFromRange(MailSetEntryTypeRef, mailSetEntriesListId, matchers.anything(), matchers.anything(), true)).thenDo( + getMailSetEntryMock, + ) + when(cacheStorage.provideMultiple(MailTypeRef, matchers.anything(), matchers.anything())).thenDo(getMailsMock) + + if (offline) { + when(entityClient.loadRange(matchers.anything(), matchers.anything(), matchers.anything(), matchers.anything(), matchers.anything())).thenReject( + new ConnectionError("sorry we are offline"), + ) + when(entityClient.loadMultiple(matchers.anything(), matchers.anything(), matchers.anything(), matchers.anything())).thenReject( + new ConnectionError("sorry we are offline"), + ) + } else { + when(entityClient.loadRange(MailSetEntryTypeRef, mailSetEntriesListId, matchers.anything(), matchers.anything(), true)).thenDo(getMailSetEntryMock) + when(entityClient.loadMultiple(MailTypeRef, matchers.anything(), matchers.anything())).thenDo(getMailsMock) + } + } + + o.test("loads PageSize items and sets labels correctly", async () => { + await setUpTestData(PageSize, labels, false, 1) + await model.loadInitial() + o(model.mails.length).equals(PageSize) + for (const mail of model.mails) { + o(model.getLabelsForMail(mail)).deepEquals(labels) + } + verify(cacheStorage.provideFromRange(MailSetEntryTypeRef, mailSetEntriesListId, CUSTOM_MAX_ID, PageSize, true), { + times: 0, + }) + verify(mailModel.getMailboxDetailsForMailFolder(matchers.anything()), { + times: 0, + }) + verify(inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, matchers.anything(), true), { + times: 0, + }) + }) + + o.test("loads PageSize items while offline and sets labels correctly", async () => { + await setUpTestData(PageSize, labels, true, 1) + await model.loadInitial() + o(model.mails.length).equals(PageSize) + for (const mail of model.mails) { + o(model.getLabelsForMail(mail)).deepEquals(labels) + } + verify(cacheStorage.provideFromRange(MailSetEntryTypeRef, mailSetEntriesListId, CUSTOM_MAX_ID, PageSize, true), { + times: 1, + }) + verify(mailModel.getMailboxDetailsForMailFolder(matchers.anything()), { + times: 0, + }) + verify(inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, matchers.anything(), true), { + times: 0, + }) + }) + + o.test("applies inbox rules if inbox", async () => { + mailSet.folderType = MailSetKind.INBOX + + // make one item have a rule + when( + inboxRuleHandler.findAndApplyMatchingRule( + mailboxDetail, + matchers.argThat((mail: Mail) => isSameId(mail._id, makeMailId(25))), + true, + ), + ).thenResolve({}) + + await setUpTestData(PageSize, labels, false, 1) + await model.loadInitial() + o(model.mails.length).equals(PageSize - 1) + for (const mail of model.mails) { + o(model.getLabelsForMail(mail)).deepEquals(labels) + } + verify(cacheStorage.provideFromRange(MailSetEntryTypeRef, mailSetEntriesListId, CUSTOM_MAX_ID, PageSize, true), { + times: 0, + }) + verify(mailModel.getMailboxDetailsForMailFolder(matchers.anything()), { + times: 1, + }) + verify(inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, matchers.anything(), true), { + times: 100, + }) + }) + + o.spec("loadMore eventually loads more mails", () => { + o.test("ungrouped", async () => { + const mailsPerConversation = 1 + const pages = 5 + await setUpTestData(PageSize * pages, labels, false, mailsPerConversation) + await model.loadInitial() // will have the first page loaded + const unloadedMail = elementIdPart(makeMailId(1)) // a mail that will be on the bottom of the list + + // This mail is not loaded until we load a few more pages. + for (let loadedPageCount = 1; loadedPageCount < pages; loadedPageCount++) { + o(model.mails.length).equals(PageSize * loadedPageCount) + const mail = model.getMail(unloadedMail) + o(mail).equals(null) + await model.loadMore() + } + + // Everything is loaded including that mail we wanted from before + o(model.mails.length).equals(PageSize * pages) + const mail = model.getMail(unloadedMail) + o(mail).notEquals(null) + }) + + o.test("grouped by conversations", async () => { + const mailsPerConversation = 2 + const pages = 4 + await setUpTestData(PageSize * pages * mailsPerConversation, labels, false, mailsPerConversation) + await model.loadInitial() // will have the first page loaded + const unloadedMail = elementIdPart(makeMailId(1)) // a mail that will be on the bottom of the list + + // This mail is not loaded until we load a few more pages. + for (let loadedPageCount = 1; loadedPageCount < pages * mailsPerConversation; loadedPageCount++) { + const mail = model.getMail(unloadedMail) + o(mail).equals(null) + await model.loadMore() + } + + // Everything is now loaded, including that mail we wanted from before + o(model.mails.length).equals(PageSize * pages * mailsPerConversation) + + // But we have fewer pages shown because half of them are hidden behind conversations where there are newer mails + o(model.items.length).equals(PageSize * pages) + const mail = model.getMail(unloadedMail) + o(mail).notEquals(null) + }) + + o.test("everything is one conversation", async () => { + const pages = 4 + const totalMails = PageSize * pages + await setUpTestData(totalMails, labels, false, totalMails) + await model.loadInitial() // will have the first page loaded + await model.loadAll() + + o(model.mails.length).equals(totalMails) + o(model.items.length).equals(1) + }) + }) + + o.test("loadAndSelect selects by mail id", async () => { + await setUpTestData(PageSize * 5, labels, false, 1) + await model.loadInitial() // will have the first page loaded + + // This mail is not loaded yet. + const unloadedMail = elementIdPart(makeMailId(1)) // a mail that will be on the bottom of the list + const mail = model.getMail(unloadedMail) + o(mail).equals(null) + + // Should now be loaded + const loadedMail = await model.loadAndSelect(unloadedMail, () => false) + o(loadedMail).notEquals(null) + o(loadedMail).equals(model.getMail(unloadedMail)) + }) + + o.spec("handleEntityUpdate", () => { + o.test("mailset update updates labels", async () => { + await setUpTestData(PageSize, [labels[0]], false, 1) + await model.loadInitial() + + // Overwrite one of the mails inside the list so it has both labels + const someIndex = 50 // a random number + const someMail: LoadedMail = { + ...model._getMailMap().get(elementIdPart(makeMailId(someIndex)))!, + labels: [labels[0], labels[1]], + } + someMail.mail.sets.push(labels[1]._id) + model._updateMails([someMail]) + o(model.getLabelsForMail(someMail.mail)[1]).deepEquals(labels[1]) + + // Change one of the labels (but not inside the mail we just updated) + labels[1] = { + ...labels[1], + name: "Mint", + color: "#00FFAA", + } + + o(model.getLabelsForMail(someMail.mail)[1]).notDeepEquals(labels[1]) + + const entityUpdateData = { + application: MailFolderTypeRef.app, + type: MailFolderTypeRef.type, + instanceListId: getListId(labels[1]), + instanceId: getElementId(labels[1]), + operation: OperationType.DELETE, + } + + entityUpdateData.operation = OperationType.UPDATE + await model.handleEntityUpdate(entityUpdateData) + o(model.getLabelsForMail(someMail.mail)[1]).deepEquals(labels[1]) + + // verify getLabelsForMail call times (someMail was queried twice, but other mails only once) + verify(mailModel.getLabelsForMail(someMail.mail), { times: 2 }) + verify(mailModel.getLabelsForMail(model.mails[someIndex + 1]), { times: 1 }) + }) + + o.test("mailset delete does nothing", async () => { + await setUpTestData(PageSize, [labels[0]], false, 1) + await model.loadInitial() + + const entityUpdateData = { + application: MailFolderTypeRef.app, + type: MailFolderTypeRef.type, + instanceListId: getListId(labels[1]), + instanceId: getElementId(labels[1]), + operation: OperationType.DELETE, + } + entityUpdateData.operation = OperationType.DELETE + + await model.handleEntityUpdate(entityUpdateData) + o(model.mails.length).equals(PageSize) + }) + + o.test("deleting a mail set entry", async () => { + await setUpTestData(PageSize, labels, false, 1) + await model.loadInitial() + const someIndex = 22 // a random number + const someMail: LoadedMail = model._getMailMap().get(elementIdPart(makeMailId(someIndex)))! + + const entityUpdateData = { + application: MailSetEntryTypeRef.app, + type: MailSetEntryTypeRef.type, + instanceListId: listIdPart(someMail.mailSetEntryId), + instanceId: elementIdPart(someMail.mailSetEntryId), + operation: OperationType.DELETE, + } + + const oldItems = model.mails + const newItems = [...oldItems] + newItems.splice(PageSize - 1 - someIndex, 1) + + o(model.mails).deepEquals(oldItems) + await model.handleEntityUpdate(entityUpdateData) + o(model.mails).deepEquals(newItems) + o(model.getMail(getElementId(someMail.mail))).equals(null) + }) + + function createInsertedMail( + mailSetEntryId: IdTuple, + conversationId: Id, + ): { + mail: Mail + mailSetEntry: MailSetEntry + entityUpdateData: EntityUpdateData + mailLabels: MailFolder[] + } { + const newMail = createTestEntity(MailTypeRef, { + _id: ["new mail!!!", deconstructMailSetEntryId(elementIdPart(mailSetEntryId)).mailId], + sets: [mailSet._id, labels[1]._id], + conversationEntry: [conversationId, "yay!"], + }) + + const newEntry = createMailSetEntry({ + _id: mailSetEntryId, + mail: newMail._id, + }) + + const entityUpdateData = { + application: MailSetEntryTypeRef.app, + type: MailSetEntryTypeRef.type, + instanceListId: getListId(newEntry), + instanceId: getElementId(newEntry), + operation: OperationType.CREATE, + } + + when(entityClient.load(MailSetEntryTypeRef, newEntry._id)).thenResolve(newEntry) + when(entityClient.loadMultiple(MailTypeRef, getListId(newMail), [getElementId(newMail)])).thenResolve([newMail]) + + return { + mail: newMail, + mailSetEntry: newEntry, + entityUpdateData, + mailLabels: [labels[1]], + } + } + + o.test("creating a mail set entry of the same set adds the element", async () => { + await setUpTestData(3, labels, false, 1) + await model.loadInitial() + const { mail, entityUpdateData, mailLabels } = createInsertedMail([mailSet.entries, CUSTOM_MAX_ID], "1") + + const oldItems = model.mails + // NOTE: mails is backed by a Map which maintains insertion order; MailSetListModel#mails is not required to + // be ordered + const newItems = [...oldItems, mail] + + o(model.mails).equals(oldItems) + await model.handleEntityUpdate(entityUpdateData) + o(model.mails).deepEquals(newItems) + o(model.getMail(getElementId(mail))).deepEquals(mail) + o(model.getLabelsForMail(mail)).deepEquals(mailLabels) + }) + + o.test("creating an older mail in an existing conversation", async () => { + await setUpTestData(1, labels, false, 1) + await model.loadInitial() + const { mail, entityUpdateData } = createInsertedMail([mailSetEntriesListId, CUSTOM_MIN_ID], "0") + + const oldMails = model.mails + const newMails = [...oldMails, mail] + + const oldItems = model.items + const newItems = [...oldItems] + + o(model.mails).deepEquals(oldMails) + o(model.items).deepEquals(oldItems) + await model.handleEntityUpdate(entityUpdateData) + o(model.mails).deepEquals(newMails) + o(model.items).deepEquals(newItems) + o(model.getMail(getElementId(mail))).equals(mail) + }) + + o.test("creating a newer mail in an existing conversation", async () => { + await setUpTestData(1, labels, false, 1) + await model.loadInitial() + const { mail, entityUpdateData } = createInsertedMail([mailSetEntriesListId, CUSTOM_MAX_ID], "0") + + const oldMails = model.mails + const newMails = [...oldMails, mail] + + const oldItems = model.items + const newItems = [mail] + + o(model.mails).deepEquals(oldMails) + o(model.items).deepEquals(oldItems) + await model.handleEntityUpdate(entityUpdateData) + o(model.mails).deepEquals(newMails) + o(model.items).deepEquals(newItems) + o(model.getMail(getElementId(mail))).equals(mail) + }) + + o.test("deleting an older mail in an existing conversation", async () => { + await setUpTestData(2, labels, false, 2) + await model.loadInitial() + + const oldMails = model.mails + const newMails = [oldMails[0]] + + const oldItems = model.items + const newItems = [...oldItems] + + const entityUpdateData = { + application: MailSetEntryTypeRef.app, + type: MailSetEntryTypeRef.type, + instanceListId: mailSetEntriesListId, + instanceId: makeMailSetElementId(0), + operation: OperationType.DELETE, + } + + o(model.mails).deepEquals(oldMails) + o(model.items).deepEquals(oldItems) + await model.handleEntityUpdate(entityUpdateData) + o(model.mails).deepEquals(newMails) + o(model.items).deepEquals(newItems) + o(model.getMail(entityUpdateData.instanceId)).equals(null) + }) + + o.test("deleting the newest mail in an existing conversation of 3 items selects the second newest", async () => { + await setUpTestData(3, labels, false, 3) + await model.loadInitial() + + const oldMails = model.mails + const newMails = [oldMails[1], oldMails[2]] + + const oldItems = model.items + const newItems = [oldMails[1]] + + const entityUpdateData = { + application: MailSetEntryTypeRef.app, + type: MailSetEntryTypeRef.type, + instanceListId: mailSetEntriesListId, + instanceId: makeMailSetElementId(2), + operation: OperationType.DELETE, + } + + o(model.mails).deepEquals(oldMails) + o(model.items).deepEquals(oldItems) + await model.handleEntityUpdate(entityUpdateData) + o(model.mails).deepEquals(newMails) + o(model.items).deepEquals(newItems) + o(model.getMail(entityUpdateData.instanceId)).equals(null) + }) + + o.test("deleting a newer mail in an existing conversation of 2 items", async () => { + await setUpTestData(2, labels, false, 2) + await model.loadInitial() + + const oldMails = model.mails + const newMails = [oldMails[1]] + + const oldItems = model.items + const newItems = [oldMails[1]] + + const entityUpdateData = { + application: MailSetEntryTypeRef.app, + type: MailSetEntryTypeRef.type, + instanceListId: mailSetEntriesListId, + instanceId: makeMailSetElementId(1), + operation: OperationType.DELETE, + } + + o(model.mails).deepEquals(oldMails) + o(model.items).deepEquals(oldItems) + await model.handleEntityUpdate(entityUpdateData) + o(model.mails).deepEquals(newMails) + o(model.items).deepEquals(newItems) + o(model.getMail(entityUpdateData.instanceId)).equals(null) + }) + + o.test("creating a mail set entry in a different set does nothing", async () => { + await setUpTestData(PageSize, labels, false, 1) + await model.loadInitial() + const { mail, entityUpdateData } = createInsertedMail(["something else", CUSTOM_MAX_ID], "whoo!") + + const oldItems = model.mails + const newItems = [...oldItems] + + o(model.mails).deepEquals(oldItems) + await model.handleEntityUpdate(entityUpdateData) + o(model.mails).deepEquals(newItems) + o(model.getMail(getElementId(mail))).equals(null) + }) + + o.test("updating a mail updates the contents", async () => { + await setUpTestData(PageSize, labels, false, 1) + await model.loadInitial() + const mail = { ...model.mails[2] } + mail.subject = "hey it's a subject" + mail.sets = [mailSet._id] // remove all labels + + const entityUpdateData = { + application: MailTypeRef.app, + type: MailTypeRef.type, + instanceListId: getListId(mail), + instanceId: getElementId(mail), + operation: OperationType.UPDATE, + } + when(entityClient.load(MailTypeRef, mail._id)).thenResolve(mail) + + entityUpdateData.operation = OperationType.UPDATE + await model.handleEntityUpdate(entityUpdateData) + o(model.getMail(getElementId(mail))).deepEquals(mail) + o(model.getLabelsForMail(mail)).deepEquals([]) + }) + + o.test("mail delete does nothing", async () => { + await setUpTestData(PageSize, labels, false, 1) + await model.loadInitial() + const mail = { ...model.mails[2] } + const entityUpdateData = { + application: MailTypeRef.app, + type: MailTypeRef.type, + instanceListId: getListId(mail), + instanceId: getElementId(mail), + operation: OperationType.UPDATE, + } + when(entityClient.load(MailTypeRef, mail._id)).thenResolve(mail) + entityUpdateData.operation = OperationType.DELETE + + await model.handleEntityUpdate(entityUpdateData) + o(model.getMail(getElementId(mail))).deepEquals(mail) + }) + }) +}) diff --git a/test/tests/mail/model/MailListModelTest.ts b/test/tests/mail/model/MailListModelTest.ts index b31aae274939..b1b9570a4ee2 100644 --- a/test/tests/mail/model/MailListModelTest.ts +++ b/test/tests/mail/model/MailListModelTest.ts @@ -1,7 +1,6 @@ import o from "../../../../packages/otest/dist/otest" -import { LoadedMail, MailListModel } from "../../../../src/mail-app/mail/model/MailListModel" +import { MailListModel } from "../../../../src/mail-app/mail/model/MailListModel" import { - createMailFolder, createMailSetEntry, Mail, MailboxGroupRootTypeRef, @@ -28,6 +27,7 @@ import { getElementId, getListId, isSameId, + listIdPart, } from "../../../../src/common/api/common/utils/EntityUtils" import { PageSize } from "../../../../src/common/gui/base/ListUtils" import { createTestEntity } from "../../TestUtils" @@ -37,6 +37,7 @@ import { MailboxDetail } from "../../../../src/common/mailFunctionality/MailboxM import { GroupInfoTypeRef, GroupTypeRef } from "../../../../src/common/api/entities/sys/TypeRefs" import { ConnectionError } from "../../../../src/common/api/common/error/RestError" import { clamp, pad } from "@tutao/tutanota-utils" +import { LoadedMail } from "../../../../src/mail-app/mail/model/MailSetListModel" o.spec("MailListModelTest", () => { let model: MailListModel @@ -157,8 +158,8 @@ o.spec("MailListModelTest", () => { } endingIndex = clamp(endingIndex, 0, mailSetEntries.length) - let startingIndex = clamp(endingIndex - count, 0, endingIndex) - return mailSetEntries.slice(startingIndex, endingIndex) + const startingIndex = clamp(endingIndex - count, 0, endingIndex) + return mailSetEntries.slice(startingIndex, endingIndex).reverse() } async function getMailsMock(_mailTypeRef: any, mailBag: string, elements: Id[]): Promise { @@ -253,19 +254,19 @@ o.spec("MailListModelTest", () => { const pages = 5 await setUpTestData(PageSize * pages, labels, false) await model.loadInitial() // will have the first page loaded - const unloadedMail = makeMailSetElementId(1) // a mail that will be on the bottom of the list + const unloadedMail = elementIdPart(makeMailId(1)) // a mail that will be on the bottom of the list // This mail is not loaded until we load a few more pages. for (let loadedPageCount = 1; loadedPageCount < pages; loadedPageCount++) { o(model.items.length).equals(PageSize * loadedPageCount) - const mail = model.getMailSetEntry(unloadedMail) + const mail = model.getMail(unloadedMail) o(mail).equals(null) await model.loadMore() } // Everything is loaded including that mail we wanted from before o(model.items.length).equals(PageSize * pages) - const mail = model.getMailSetEntry(unloadedMail) + const mail = model.getMail(unloadedMail) o(mail).notEquals(null) }) @@ -351,8 +352,8 @@ o.spec("MailListModelTest", () => { const entityUpdateData = { application: MailSetEntryTypeRef.app, type: MailSetEntryTypeRef.type, - instanceListId: getListId(someMail.mailSetEntry), - instanceId: getElementId(someMail.mailSetEntry), + instanceListId: listIdPart(someMail.mailSetEntryId), + instanceId: elementIdPart(someMail.mailSetEntryId), operation: OperationType.DELETE, } diff --git a/test/tests/misc/DeviceConfigTest.ts b/test/tests/misc/DeviceConfigTest.ts index 8b2612c84fc4..53136e0bca5f 100644 --- a/test/tests/misc/DeviceConfigTest.ts +++ b/test/tests/misc/DeviceConfigTest.ts @@ -1,5 +1,11 @@ import o from "@tutao/otest" -import { DeviceConfig, DeviceConfigCredentials, ListAutoSelectBehavior, migrateConfig, migrateConfigV2to3 } from "../../../src/common/misc/DeviceConfig.js" +import { + DeviceConfig, + DeviceConfigCredentials, + ListAutoSelectBehavior, + MailListDisplayMode, + migrateConfigV2to3, +} from "../../../src/common/misc/DeviceConfig.js" import { matchers, object, when } from "testdouble" import { verify } from "@tutao/tutanota-test-utils" import { CredentialEncryptionMode } from "../../../src/common/misc/credentials/CredentialEncryptionMode.js" @@ -7,7 +13,7 @@ import { CredentialType } from "../../../src/common/misc/credentials/CredentialT o.spec("DeviceConfig", function () { o.spec("migrateConfig", function () { - o("migrating from v2 to v3 preserves internal logins", function () { + o.test("migrating from v2 to v3 preserves internal logins", function () { const oldConfig: any = { _version: 2, _credentials: [ @@ -53,6 +59,30 @@ o.spec("DeviceConfig", function () { o(oldConfig._credentials).deepEquals(expectedCredentialsAfterMigration) }) + + o.test("migrating from v4 to v5 sets mailListDisplayMode to MAILS", function () { + const oldConfig: any = { + _version: 2, + _credentials: [ + { + mailAddress: "internal@example.com", + userId: "internalUserId", + accessToken: "internalAccessToken", + encryptedPassword: "internalEncPassword", + }, + { + mailAddress: "externalUserId", + userId: "externalUserId", + accessToken: "externalAccessToken", + encryptedPassword: "externalEncPassword", + }, + ], + } + + const localStorageMock = object() + when(localStorageMock.getItem(DeviceConfig.LocalStorageKey)).thenReturn(JSON.stringify(oldConfig)) + o.check(new DeviceConfig(localStorageMock).getMailListDisplayMode()).equals(MailListDisplayMode.MAILS) + }) }) o.spec("loading config", function () { @@ -70,7 +100,7 @@ o.spec("DeviceConfig", function () { }), ) - new DeviceConfig(DeviceConfig.Version, localStorageMock) + new DeviceConfig(localStorageMock) verify(localStorageMock.setItem(DeviceConfig.LocalStorageKey, matchers.anything()), { times: 0 }) }) @@ -109,6 +139,7 @@ o.spec("DeviceConfig", function () { events: [], lastRatingPromptedDate: null, retryRatingPromptAfter: null, + mailListDisplayMode: MailListDisplayMode.MAILS, } when(localStorageMock.getItem(DeviceConfig.LocalStorageKey)).thenReturn(JSON.stringify(storedInLocalStorage)) @@ -118,7 +149,7 @@ o.spec("DeviceConfig", function () { storedJson = json }) - new DeviceConfig(DeviceConfig.Version, localStorageMock) + new DeviceConfig(localStorageMock) const migratedConfig = Object.assign({}, storedInLocalStorage, { _version: DeviceConfig.Version, @@ -141,5 +172,12 @@ o.spec("DeviceConfig", function () { // We can't just call verify on localStorageMock.setItem because the JSON string may not match perfectly o(JSON.parse(storedJson)).deepEquals(migratedConfig) }) + + o.test("new config has MailListDisplayMode CONVERSATION", function () { + const localStorageMock = object() + when(localStorageMock.getItem(DeviceConfig.LocalStorageKey)).thenReturn(null) + + o.check(new DeviceConfig(localStorageMock).getMailListDisplayMode()).equals(MailListDisplayMode.CONVERSATIONS) + }) }) })