diff --git a/src/mail-app/mail/model/ConversationListModel.ts b/src/mail-app/mail/model/ConversationListModel.ts index b73a4645e1b2..fe3bb5a98b97 100644 --- a/src/mail-app/mail/model/ConversationListModel.ts +++ b/src/mail-app/mail/model/ConversationListModel.ts @@ -27,7 +27,7 @@ import { isSameId, listIdPart, } from "../../../common/api/common/utils/EntityUtils" -import { assertNotNull, compare, isNotNull } from "@tutao/tutanota-utils" +import { assertNotNull, compare, first, isNotNull } 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" @@ -46,8 +46,11 @@ export class ConversationListModel implements MailSetListModel { // Map conversation IDs (to ensure unique conversations) private readonly conversationMap: Map = new Map() - // keep a reverse map for going from Mail element id -> LoadedConversation - private readonly mailMap: Map = new Map() + // keep a reverse map for going from Mail element id -> LoadedMail + private readonly mailMap: Map = new Map() + + // override selected email + private olderMailSelection: Id | null = null constructor( private readonly mailSet: MailFolder, @@ -90,7 +93,7 @@ export class ConversationListModel implements MailSetListModel { } getMail(mailId: Id): Mail | null { - return this.getConversationForMailById(mailId)?.latestMail?.mail ?? null + return this.mailMap.get(mailId)?.mail ?? null } getSelectedAsArray(): Mail[] { @@ -106,12 +109,12 @@ export class ConversationListModel implements MailSetListModel { if (update.operation === OperationType.UPDATE) { const mailSetId: IdTuple = [update.instanceListId, update.instanceId] for (const loadedMail of this.mailMap.values()) { - const hasMailSet = loadedMail.latestMail.labels.some((label) => isSameId(mailSetId, label._id)) + 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.latestMail.mail) + const labels = this.mailModel.getLabelsForMail(loadedMail.mail) const newMailEntry = { ...loadedMail, labels, @@ -122,7 +125,7 @@ export class ConversationListModel implements MailSetListModel { } else if (isUpdateForTypeRef(MailSetEntryTypeRef, update) && isSameId(this.mailSet.entries, update.instanceListId)) { if (update.operation === OperationType.DELETE) { const { mailId } = deconstructMailSetEntryId(update.instanceId) - const conversation = this.mailMap.get(mailId) + const conversation = this.getConversationForMailById(mailId) if (!conversation) { return } @@ -221,22 +224,18 @@ export class ConversationListModel implements MailSetListModel { 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.latestMail, + ...mailItem, labels, mail: newMailData, } - this._updateSingleMail({ - ...mailItem, - latestMail: loadedMail, - }) + this._updateSingleMail(loadedMail) } } } // @VisibleForTesting - _updateSingleMail(loadedConversation: LoadedConversation) { - this.updateMailMaps(loadedConversation) - this.listModel.updateLoadedItem(loadedConversation) + _updateSingleMail(loadedMail: LoadedMail) { + this.mailMap.set(getElementId(loadedMail.mail), loadedMail) } private async loadSingleMail(id: IdTuple): Promise<{ @@ -274,11 +273,23 @@ export class ConversationListModel implements MailSetListModel { } async loadAndSelect(mailId: string, shouldStop: () => boolean): Promise { - // loadAndSelect doesn't actually use the mailId if we pass our own finder in, so we can pass a finder that - // looks for a specific mail ID const mailFinder = (loadedConversation: LoadedConversation) => isSameId(getElementId(loadedConversation.latestMail.mail), mailId) - const mail = await this.listModel.loadAndSelect(mailFinder, shouldStop) - return mail?.latestMail?.mail ?? null + + // 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(getElementId(conversation.latestMail.mail), selectedMailId)) { + this.olderMailSelection = selectedMailId + this.listModel.onSingleSelection(conversation) + } + } + return selectedMail } async loadInitial() { @@ -331,6 +342,18 @@ export class ConversationListModel implements MailSetListModel { get stateStream(): Stream> { return this.listModel.stateStream.map((state) => { + if (this.olderMailSelection) { + const olderMail = this.getMail(this.olderMailSelection) + if ( + olderMail == null || + state.selectedItems.size !== 1 || + state.inMultiselect || + [...state.selectedItems][0].conversationId !== listIdPart(olderMail.conversationEntry) + ) { + this.olderMailSelection = null + } + } + const items = state.items.map((item) => item.latestMail.mail) const selectedItems: Set = new Set() for (const item of state.selectedItems) { @@ -353,13 +376,27 @@ export class ConversationListModel implements MailSetListModel { this.listModel.onSingleExclusiveSelection(assertNotNull(this.getConversationForMail(mail))) } + getDisplayedMail(): Mail | null { + if (this.olderMailSelection != null) { + return this.getMail(this.olderMailSelection) + } 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 { - return this.mailMap.get(mailId) ?? 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 { @@ -433,11 +470,12 @@ export class ConversationListModel implements MailSetListModel { const existingConversation = this.conversationMap.get(conversation.conversationId) if (existingConversation != null && this.reverseSortConversation(conversation, existingConversation) >= 0) { + // store it so we can load it later + this._updateSingleMail(mail) continue } if (existingConversation != null) { - this.mailMap.delete(getElementId(existingConversation.latestMail.mail)) deletedItems.push(existingConversation) } @@ -449,7 +487,7 @@ export class ConversationListModel implements MailSetListModel { } private updateMailMaps(conversation: LoadedConversation) { - this.mailMap.set(getElementId(conversation.latestMail.mail), conversation) + this.mailMap.set(getElementId(conversation.latestMail.mail), conversation.latestMail) this.conversationMap.set(conversation.conversationId, conversation) } diff --git a/src/mail-app/mail/model/MailListModel.ts b/src/mail-app/mail/model/MailListModel.ts index b06c9f5950de..8d07d142cd83 100644 --- a/src/mail-app/mail/model/MailListModel.ts +++ b/src/mail-app/mail/model/MailListModel.ts @@ -12,7 +12,7 @@ import { import { EntityClient } from "../../../common/api/common/EntityClient" import { ConversationPrefProvider } from "../view/ConversationViewModel" import { assertMainOrNode } from "../../../common/api/common/Env" -import { assertNotNull, compare } from "@tutao/tutanota-utils" +import { assertNotNull, compare, first } 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" @@ -252,6 +252,14 @@ export class MailListModel implements MailSetListModel { 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 } diff --git a/src/mail-app/mail/model/MailSetListModel.ts b/src/mail-app/mail/model/MailSetListModel.ts index fed70ac86f1c..4d956cad00d2 100644 --- a/src/mail-app/mail/model/MailSetListModel.ts +++ b/src/mail-app/mail/model/MailSetListModel.ts @@ -70,6 +70,8 @@ export interface MailSetListModel { cancelLoadAll(): void isLoadingAll(): boolean + + getDisplayedMail(): Mail | null } /** diff --git a/src/mail-app/mail/view/MailViewModel.ts b/src/mail-app/mail/view/MailViewModel.ts index 981d97f848e7..aea7afc4764b 100644 --- a/src/mail-app/mail/view/MailViewModel.ts +++ b/src/mail-app/mail/view/MailViewModel.ts @@ -474,9 +474,7 @@ export class MailViewModel { 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 + : assertNotNull(this.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(