fix minor issues with new MailSet implementation after review #7429

This commit is contained in:
jhm 2024-08-28 10:33:35 +02:00 committed by Johannes Münichsdorfer
parent 0d14919a20
commit e0ce2ac678
11 changed files with 86 additions and 104 deletions

View file

@ -396,7 +396,9 @@ export class CalendarSearchViewModel {
// note in case of refactor: the fact that the list updates the URL every time it changes // note in case of refactor: the fact that the list updates the URL every time it changes
// its state is a major source of complexity and makes everything very order-dependent // its state is a major source of complexity and makes everything very order-dependent
return new ListModel<SearchResultListEntry>({ return new ListModel<SearchResultListEntry>({
fetch: async (startId: Id, count: number) => { fetch: async (lastFetchedEntity: SearchResultListEntry, count: number) => {
const startId = lastFetchedEntity == null ? GENERATED_MAX_ID : getElementId(lastFetchedEntity)
const lastResult = this._searchResult const lastResult = this._searchResult
if (lastResult !== this._searchResult) { if (lastResult !== this._searchResult) {
console.warn("got a fetch request for outdated results object, ignoring") console.warn("got a fetch request for outdated results object, ignoring")

View file

@ -378,10 +378,6 @@ export class DefaultEntityRestCache implements EntityRestCache {
try { try {
const range = await this.storage.getRangeForList(typeRef, listId) const range = await this.storage.getRangeForList(typeRef, listId)
if (getTypeId(typeRef) == "tutanota/MailSetEntry") {
console.log(getTypeId(typeRef), listId, start, count, reverse)
console.log("range", range)
}
if (range == null) { if (range == null) {
await this.populateNewListWithRange(typeRef, listId, start, count, reverse) await this.populateNewListWithRange(typeRef, listId, start, count, reverse)
} else if (isStartIdWithinRange(range, start, typeModel)) { } else if (isStartIdWithinRange(range, start, typeModel)) {

View file

@ -1,4 +1,4 @@
import { elementIdPart, GENERATED_MAX_ID, getElementId, isSameId, ListElement } from "../api/common/utils/EntityUtils.js" import { elementIdPart, getElementId, isSameId, ListElement } from "../api/common/utils/EntityUtils.js"
import { ListLoadingState, ListState } from "../gui/base/List.js" import { ListLoadingState, ListState } from "../gui/base/List.js"
import { OperationType } from "../api/common/TutanotaConstants.js" import { OperationType } from "../api/common/TutanotaConstants.js"
@ -29,14 +29,7 @@ export type ListModelConfig<ListElementType> = {
/** /**
* Get the given number of entities starting after the given id. May return more elements than requested, e.g. if all elements are available on first fetch. * Get the given number of entities starting after the given id. May return more elements than requested, e.g. if all elements are available on first fetch.
*/ */
fetch(startId: Id, count: number): Promise<ListFetchResult<ListElementType>> fetch(lastFetchedEntity: ListElementType | null | undefined, count: number): Promise<ListFetchResult<ListElementType>>
/**
* some lists load elements via an index indirection,
* so they use a different list to load than is actually displayed.
* in fact, the displayed entities might not even be stored in the same list
*/
getLoadIdForElement?: (element: ListElementType | null | undefined) => Id
/** /**
* Returns null if the given element could not be loaded * Returns null if the given element could not be loaded
@ -58,14 +51,7 @@ type PrivateListState<ElementType> = Omit<ListState<ElementType>, "items" | "act
/** ListModel that does the state upkeep for the List, including loading state, loaded items, selection and filters*/ /** ListModel that does the state upkeep for the List, including loading state, loaded items, selection and filters*/
export class ListModel<ElementType extends ListElement> { export class ListModel<ElementType extends ListElement> {
private readonly config: Required<ListModelConfig<ElementType>> constructor(private readonly config: ListModelConfig<ElementType>) {}
constructor(config: ListModelConfig<ElementType>) {
if (config.getLoadIdForElement == null) {
config.getLoadIdForElement = (element: ElementType) => (element != null ? getElementId(element) : GENERATED_MAX_ID)
}
this.config = config as Required<ListModelConfig<ElementType>>
}
private loadState: "created" | "initialized" = "created" private loadState: "created" | "initialized" = "created"
private loading: Promise<unknown> = Promise.resolve() private loading: Promise<unknown> = Promise.resolve()
@ -163,10 +149,9 @@ export class ListModel<ElementType extends ListElement> {
private async doLoad() { private async doLoad() {
this.updateLoadingStatus(ListLoadingState.Loading) this.updateLoadingStatus(ListLoadingState.Loading)
this.loading = Promise.resolve().then(async () => { this.loading = Promise.resolve().then(async () => {
const lastItem = last(this.rawState.unfilteredItems) const lastFetchedItem = last(this.rawState.unfilteredItems)
try { try {
const idToLoadFrom = this.config.getLoadIdForElement(lastItem) const { items: newItems, complete } = await this.config.fetch(lastFetchedItem, PageSize)
const { items: newItems, complete } = await this.config.fetch(idToLoadFrom, PageSize)
// if the loading was cancelled in the meantime, don't insert anything so that it's not confusing // if the loading was cancelled in the meantime, don't insert anything so that it's not confusing
if (this.state.loadingStatus === ListLoadingState.ConnectionLost) { if (this.state.loadingStatus === ListLoadingState.ConnectionLost) {
return return
@ -206,10 +191,6 @@ export class ListModel<ElementType extends ListElement> {
this.updateState({ filteredItems: newFilteredItems, selectedItems: newSelectedItems }) this.updateState({ filteredItems: newFilteredItems, selectedItems: newSelectedItems })
} }
isFiltered(): boolean {
return this.filter != null
}
async entityEventReceived(listId: Id, elementId: Id, operation: OperationType): Promise<void> { async entityEventReceived(listId: Id, elementId: Id, operation: OperationType): Promise<void> {
if (operation === OperationType.CREATE || operation === OperationType.UPDATE) { if (operation === OperationType.CREATE || operation === OperationType.UPDATE) {
// load the element without range checks for now // load the element without range checks for now
@ -251,7 +232,7 @@ export class ListModel<ElementType extends ListElement> {
} }
private updateLoadedEntity(entity: ElementType) { private updateLoadedEntity(entity: ElementType) {
// We cannot use binary search here because the sort order of items can change based on the entity update and we need to find the position of the // We cannot use binary search here because the sort order of items can change based on the entity update, and we need to find the position of the
// old entity by id in order to remove it. // old entity by id in order to remove it.
// Since every element id is unique and there's no scenario where the same item appears twice but in different lists, we can safely sort just // Since every element id is unique and there's no scenario where the same item appears twice but in different lists, we can safely sort just
@ -591,7 +572,7 @@ export class ListModel<ElementType extends ListElement> {
stopLoading() { stopLoading() {
if (this.state.loadingStatus === ListLoadingState.Loading) { if (this.state.loadingStatus === ListLoadingState.Loading) {
// We can't really cancel ongoing requests but we can prevent more requests from happening // We can't really cancel ongoing requests, but we can prevent more requests from happening
this.updateState({ loadingStatus: ListLoadingState.ConnectionLost }) this.updateState({ loadingStatus: ListLoadingState.ConnectionLost })
} }
} }

View file

@ -11,7 +11,7 @@ import { Icons } from "../gui/base/icons/Icons.js"
import { BootIcons } from "../gui/base/icons/BootIcons.js" import { BootIcons } from "../gui/base/icons/BootIcons.js"
import { compareGroupInfos } from "../api/common/utils/GroupUtils.js" import { compareGroupInfos } from "../api/common/utils/GroupUtils.js"
import { elementIdPart, GENERATED_MAX_ID } from "../api/common/utils/EntityUtils.js" import { elementIdPart } from "../api/common/utils/EntityUtils.js"
import { ListColumnWrapper } from "../gui/ListColumnWrapper.js" import { ListColumnWrapper } from "../gui/ListColumnWrapper.js"
import { assertMainOrNode } from "../api/common/Env.js" import { assertMainOrNode } from "../api/common/Env.js"
import { locator } from "../api/main/CommonLocator.js" import { locator } from "../api/main/CommonLocator.js"
@ -217,10 +217,7 @@ export class UserListView implements UpdatableSettingsViewer {
private makeListModel(): ListModel<GroupInfo> { private makeListModel(): ListModel<GroupInfo> {
const listModel = new ListModel<GroupInfo>({ const listModel = new ListModel<GroupInfo>({
sortCompare: compareGroupInfos, sortCompare: compareGroupInfos,
fetch: async (startId) => { fetch: async (_lastFetchedEntity) => {
if (startId !== GENERATED_MAX_ID) {
throw new Error("fetch user group infos called for specific start id")
}
await this.loadAdmins() await this.loadAdmins()
const listId = await this.listId.getAsync() const listId = await this.listId.getAsync()
const allUserGroupInfos = await locator.entityClient.loadAll(GroupInfoTypeRef, listId) const allUserGroupInfos = await locator.entityClient.loadAll(GroupInfoTypeRef, listId)

View file

@ -22,7 +22,7 @@ import { isSpamOrTrashFolder } from "../../../common/api/common/CommonMailUtils.
export interface MailFolderViewAttrs { export interface MailFolderViewAttrs {
mailboxDetail: MailboxDetail mailboxDetail: MailboxDetail
mailFolderToSelectedMail: ReadonlyMap<MailFolder, Id> mailFolderElementIdToSelectedMailId: ReadonlyMap<Id, Id>
onFolderClick: (folder: MailFolder) => unknown onFolderClick: (folder: MailFolder) => unknown
onFolderDrop: (mailId: string, folder: MailFolder) => unknown onFolderDrop: (mailId: string, folder: MailFolder) => unknown
expandedFolders: ReadonlySet<Id> expandedFolders: ReadonlySet<Id>
@ -93,12 +93,12 @@ export class MailFoldersView implements Component<MailFolderViewAttrs> {
if (attrs.inEditMode) { if (attrs.inEditMode) {
return m.route.get() return m.route.get()
} else { } else {
const mailId = attrs.mailFolderToSelectedMail.get(system.folder) const folderElementId = getElementId(system.folder)
const folderId = getElementId(system.folder) const mailId = attrs.mailFolderElementIdToSelectedMailId.get(folderElementId)
if (mailId) { if (mailId) {
return `${MAIL_PREFIX}/${folderId}/${mailId}` return `${MAIL_PREFIX}/${folderElementId}/${mailId}`
} else { } else {
return `${MAIL_PREFIX}/${folderId}` return `${MAIL_PREFIX}/${folderElementId}`
} }
} }
}, },

View file

@ -593,7 +593,7 @@ export class MailView extends BaseTopLevelView implements TopLevelView<MailViewA
return m(MailFoldersView, { return m(MailFoldersView, {
mailboxDetail, mailboxDetail,
expandedFolders: this.expandedState, expandedFolders: this.expandedState,
mailFolderToSelectedMail: this.mailViewModel.getMailFolderToSelectedMail(), mailFolderElementIdToSelectedMailId: this.mailViewModel.getMailFolderToSelectedMail(),
onFolderClick: () => { onFolderClick: () => {
if (!inEditMode) { if (!inEditMode) {
this.viewSlider.focus(this.listColumn) this.viewSlider.focus(this.listColumn)

View file

@ -1,13 +1,13 @@
import { ListModel, ListModelConfig } from "../../../common/misc/ListModel.js" import { ListModel } from "../../../common/misc/ListModel.js"
import { MailboxDetail, MailModel } from "../../../common/mailFunctionality/MailModel.js" import { MailboxDetail, MailModel } from "../../../common/mailFunctionality/MailModel.js"
import { EntityClient } from "../../../common/api/common/EntityClient.js" import { EntityClient } from "../../../common/api/common/EntityClient.js"
import { Mail, MailFolder, MailSetEntry, MailSetEntryTypeRef, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js" import { Mail, MailFolder, MailSetEntry, MailSetEntryTypeRef, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { import {
constructMailSetEntryId, constructMailSetEntryId,
CUSTOM_MAX_ID, CUSTOM_MAX_ID,
customIdToUint8array,
elementIdPart, elementIdPart,
firstBiggerThanSecond, firstBiggerThanSecond,
GENERATED_MAX_ID,
getElementId, getElementId,
isSameId, isSameId,
listIdPart, listIdPart,
@ -15,7 +15,6 @@ import {
} from "../../../common/api/common/utils/EntityUtils.js" } from "../../../common/api/common/utils/EntityUtils.js"
import { import {
assertNotNull, assertNotNull,
compare,
count, count,
debounce, debounce,
groupByAndMap, groupByAndMap,
@ -51,11 +50,23 @@ export interface MailOpenedListener {
onEmailOpened(mail: Mail): unknown onEmailOpened(mail: Mail): unknown
} }
/** sort mail set mails in descending order according to their receivedDate, not their element id */ /** sort mail set mails in descending order (**reversed**: newest to oldest) according to their receivedDate, not their elementId */
function sortCompareMailSetMails(firstMail: Mail, secondMail: Mail): number { function sortCompareMailSetMails(firstMail: Mail, secondMail: Mail): number {
const firstMailEntryId = constructMailSetEntryId(firstMail.receivedDate, getElementId(firstMail)) const firstMailReceivedTimestamp = firstMail.receivedDate.getTime()
const secondMailEntryId = constructMailSetEntryId(secondMail.receivedDate, getElementId(secondMail)) const secondMailReceivedTimestamp = secondMail.receivedDate.getTime()
return compare(customIdToUint8array(secondMailEntryId), customIdToUint8array(firstMailEntryId)) if (firstMailReceivedTimestamp > secondMailReceivedTimestamp) {
return -1
} else if (secondMailReceivedTimestamp < firstMailReceivedTimestamp) {
return 1
} else {
if (firstBiggerThanSecond(getElementId(firstMail), getElementId(secondMail))) {
return -1
} else if (firstBiggerThanSecond(getElementId(secondMail), getElementId(firstMail))) {
return 1
} else {
return 0
}
}
} }
/** ViewModel for the overall mail view. */ /** ViewModel for the overall mail view. */
@ -63,7 +74,7 @@ export class MailViewModel {
private _folder: MailFolder | null = null private _folder: MailFolder | null = null
/** id of the mail we are trying to load based on the URL */ /** id of the mail we are trying to load based on the URL */
private targetMailId: Id | null = null private targetMailId: Id | null = null
/** needed to prevent parallel target loads*/ /** needed to prevent parallel target loads */
private loadingToTargetId: Id | null = null private loadingToTargetId: Id | null = null
private conversationViewModel: ConversationViewModel | null = null private conversationViewModel: ConversationViewModel | null = null
private _filterType: MailFilterType | null = null private _filterType: MailFilterType | null = null
@ -72,7 +83,7 @@ export class MailViewModel {
* We remember the last URL used for each folder so if we switch between folders we can keep the selected mail. * We remember the last URL used for each folder so if we switch between folders we can keep the selected mail.
* There's a similar (but different) hacky mechanism where we store last URL but per each top-level view: navButtonRoutes. This one is per folder. * There's a similar (but different) hacky mechanism where we store last URL but per each top-level view: navButtonRoutes. This one is per folder.
*/ */
private mailFolderToSelectedMail: ReadonlyMap<MailFolder, Id> = new Map() private mailFolderElementIdToSelectedMailId: ReadonlyMap<Id, Id> = new Map()
private listStreamSubscription: Stream<unknown> | null = null private listStreamSubscription: Stream<unknown> | null = null
private conversationPref: boolean = false private conversationPref: boolean = false
@ -117,7 +128,7 @@ export class MailViewModel {
} }
// important to set it early enough because setting listId will trigger URL update. // important to set it early enough because setting listId will trigger URL update.
// if we don't set this one before setListId, url update will cause this function to be called again but without target mail and we will lose the // if we don't set this one before setListId, url update will cause this function to be called again but without target mail, and we will lose the
// target URL // target URL
this.targetMailId = typeof mailId === "string" ? mailId : null this.targetMailId = typeof mailId === "string" ? mailId : null
@ -135,9 +146,9 @@ export class MailViewModel {
await this.setListId(folderToUse) await this.setListId(folderToUse)
// if there is a target id and we are not loading for this id already then start loading towards that id // if there is a target id, and we are not loading for this id already then start loading towards that id
if (this.targetMailId && this.targetMailId != this.loadingToTargetId) { if (this.targetMailId && this.targetMailId != this.loadingToTargetId) {
this.mailFolderToSelectedMail = mapWith(this.mailFolderToSelectedMail, folderToUse, this.targetMailId) this.mailFolderElementIdToSelectedMailId = mapWith(this.mailFolderElementIdToSelectedMailId, getElementId(folderToUse), this.targetMailId)
try { try {
this.loadingToTargetId = this.targetMailId this.loadingToTargetId = this.targetMailId
await this.loadAndSelectMail(folderToUse, this.targetMailId) await this.loadAndSelectMail(folderToUse, this.targetMailId)
@ -198,8 +209,8 @@ export class MailViewModel {
return this._folder ? this.listModelForFolder(getElementId(this._folder)) : null return this._folder ? this.listModelForFolder(getElementId(this._folder)) : null
} }
getMailFolderToSelectedMail(): ReadonlyMap<MailFolder, Id> { getMailFolderToSelectedMail(): ReadonlyMap<Id, Id> {
return this.mailFolderToSelectedMail return this.mailFolderElementIdToSelectedMailId
} }
getFolder(): MailFolder | null { getFolder(): MailFolder | null {
@ -224,14 +235,19 @@ export class MailViewModel {
return this.conversationViewModel return this.conversationViewModel
} }
private listModelForFolder = memoized((folderId: Id) => { private listModelForFolder = memoized((_folderId: Id) => {
const customGetLoadIdForElement: ListModelConfig<Mail>["getLoadIdForElement"] = (mail) =>
mail == null ? CUSTOM_MAX_ID : constructMailSetEntryId(mail.receivedDate, getElementId(mail))
return new ListModel<Mail>({ return new ListModel<Mail>({
getLoadIdForElement: this._folder?.isMailSet ? customGetLoadIdForElement : undefined, fetch: async (lastFetchedMail, count) => {
fetch: async (startId, count) => {
const folder = assertNotNull(this._folder) const folder = assertNotNull(this._folder)
// in case the folder is a new MailSet folder we need to load via the MailSetEntry index indirection
let startId: Id
if (folder.isMailSet) {
startId = lastFetchedMail == null ? CUSTOM_MAX_ID : constructMailSetEntryId(lastFetchedMail.receivedDate, getElementId(lastFetchedMail))
} else {
startId = lastFetchedMail == null ? GENERATED_MAX_ID : getElementId(lastFetchedMail)
}
const { complete, items } = await this.loadMailRange(folder, startId, count) const { complete, items } = await this.loadMailRange(folder, startId, count)
if (complete) { if (complete) {
this.fixCounterIfNeeded(folder, []) this.fixCounterIfNeeded(folder, [])
@ -239,7 +255,6 @@ export class MailViewModel {
return { complete, items } return { complete, items }
}, },
loadSingle: async (listId: Id, elementId: Id): Promise<Mail | null> => { loadSingle: async (listId: Id, elementId: Id): Promise<Mail | null> => {
// await this.entityClient.load(MailSetEntryTypeRef, )
return this.entityClient.load(MailTypeRef, [listId, elementId]) return this.entityClient.load(MailTypeRef, [listId, elementId])
}, },
sortCompare: (firstMail, secondMail): number => sortCompare: (firstMail, secondMail): number =>
@ -284,7 +299,11 @@ export class MailViewModel {
if (!newState.inMultiselect && newState.selectedItems.size === 1) { if (!newState.inMultiselect && newState.selectedItems.size === 1) {
const mail = this.listModel!.getSelectedAsArray()[0] const mail = this.listModel!.getSelectedAsArray()[0]
if (!this.conversationViewModel || !isSameId(this.conversationViewModel?.primaryMail._id, mail._id)) { if (!this.conversationViewModel || !isSameId(this.conversationViewModel?.primaryMail._id, mail._id)) {
this.mailFolderToSelectedMail = mapWith(this.mailFolderToSelectedMail, assertNotNull(this.getFolder()), getElementId(mail)) this.mailFolderElementIdToSelectedMailId = mapWith(
this.mailFolderElementIdToSelectedMailId,
getElementId(assertNotNull(this.getFolder())),
getElementId(mail),
)
this.createConversationViewModel({ this.createConversationViewModel({
mail, mail,
@ -295,7 +314,7 @@ export class MailViewModel {
} else { } else {
this.conversationViewModel?.dispose() this.conversationViewModel?.dispose()
this.conversationViewModel = null this.conversationViewModel = null
this.mailFolderToSelectedMail = mapWithout(this.mailFolderToSelectedMail, assertNotNull(this.getFolder())) this.mailFolderElementIdToSelectedMailId = mapWithout(this.mailFolderElementIdToSelectedMailId, getElementId(assertNotNull(this.getFolder())))
} }
this.updateUrl() this.updateUrl()
this.updateUi() this.updateUi()
@ -304,7 +323,7 @@ export class MailViewModel {
private updateUrl() { private updateUrl() {
const folder = this._folder const folder = this._folder
const folderId = folder ? getElementId(folder) : null const folderId = folder ? getElementId(folder) : null
const mailId = this.targetMailId ?? (folder ? this.getMailFolderToSelectedMail().get(folder) : null) const mailId = this.targetMailId ?? (folderId ? this.getMailFolderToSelectedMail().get(folderId) : null)
if (mailId != null) { if (mailId != null) {
this.router.routeTo("/mail/:folderId/:mailId", { folderId, mailId }) this.router.routeTo("/mail/:folderId/:mailId", { folderId, mailId })
} else { } else {
@ -370,7 +389,6 @@ export class MailViewModel {
} }
private async loadMailSetMailRange(folder: MailFolder, startId: string, count: number): Promise<ListFetchResult<Mail>> { private async loadMailSetMailRange(folder: MailFolder, startId: string, count: number): Promise<ListFetchResult<Mail>> {
console.log("range request for ", folder.entries, startId, count)
try { try {
const loadMailSetEntries = () => this.entityClient.loadRange(MailSetEntryTypeRef, folder.entries, startId, count, true) const loadMailSetEntries = () => this.entityClient.loadRange(MailSetEntryTypeRef, folder.entries, startId, count, true)
const loadMails = (listId: Id, mailIds: Array<Id>) => this.entityClient.loadMultiple(MailTypeRef, listId, mailIds) const loadMails = (listId: Id, mailIds: Array<Id>) => this.entityClient.loadMultiple(MailTypeRef, listId, mailIds)
@ -379,7 +397,7 @@ export class MailViewModel {
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(folder) const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(folder)
// For inbox rules there are two points where we might want to apply them. The first one is MailModel which applied inbox rules as they are received // For inbox rules there are two points where we might want to apply them. The first one is MailModel which applied inbox rules as they are received
// in real time. The second one is here, when we load emails in inbox. If they are unread we want to apply inbox rules to them. If inbox rule // in real time. The second one is here, when we load emails in inbox. If they are unread we want to apply inbox rules to them. If inbox rule
// applies, the email is moved out of the inbox and we don't return it here. // applies, the email is moved out of the inbox, and we don't return it here.
if (mailboxDetail) { if (mailboxDetail) {
const mailsToKeepInInbox = await promiseFilter(mails, async (mail) => { const mailsToKeepInInbox = await promiseFilter(mails, async (mail) => {
const wasMatched = await this.inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, mail, true) const wasMatched = await this.inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, mail, true)
@ -391,7 +409,7 @@ export class MailViewModel {
} }
} catch (e) { } catch (e) {
// 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. // 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 // 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, // 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. // 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, // 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,
@ -436,7 +454,7 @@ export class MailViewModel {
const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(folder) const mailboxDetail = await this.mailModel.getMailboxDetailsForMailFolder(folder)
// For inbox rules there are two points where we might want to apply them. The first one is MailModel which applied inbox rules as they are received // For inbox rules there are two points where we might want to apply them. The first one is MailModel which applied inbox rules as they are received
// in real time. The second one is here, when we load emails in inbox. If they are unread we want to apply inbox rules to them. If inbox rule // in real time. The second one is here, when we load emails in inbox. If they are unread we want to apply inbox rules to them. If inbox rule
// applies, the email is moved out of the inbox and we don't return it here. // applies, the email is moved out of the inbox, and we don't return it here.
if (mailboxDetail) { if (mailboxDetail) {
const mailsToKeepInInbox = await promiseFilter(items, async (mail) => { const mailsToKeepInInbox = await promiseFilter(items, async (mail) => {
const wasMatched = await this.inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, mail, true) const wasMatched = await this.inboxRuleHandler.findAndApplyMatchingRule(mailboxDetail, mail, true)
@ -448,7 +466,7 @@ export class MailViewModel {
} }
} catch (e) { } catch (e) {
// 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. // 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 // 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, // 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. // 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, // 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,
@ -467,7 +485,7 @@ export class MailViewModel {
async switchToFolder(folderType: Omit<MailSetKind, MailSetKind.CUSTOM>): Promise<void> { async switchToFolder(folderType: Omit<MailSetKind, MailSetKind.CUSTOM>): Promise<void> {
const mailboxDetail = assertNotNull(await this.getMailboxDetails()) const mailboxDetail = assertNotNull(await this.getMailboxDetails())
const folder = assertSystemFolderOfType(mailboxDetail.folders, folderType) const folder = assertSystemFolderOfType(mailboxDetail.folders, folderType)
await this.showMail(folder, this.mailFolderToSelectedMail.get(folder)) await this.showMail(folder, this.mailFolderElementIdToSelectedMailId.get(getElementId(folder)))
} }
async getMailboxDetails(): Promise<MailboxDetail> { async getMailboxDetails(): Promise<MailboxDetail> {

View file

@ -685,7 +685,9 @@ export class SearchViewModel {
// note in case of refactor: the fact that the list updates the URL every time it changes // note in case of refactor: the fact that the list updates the URL every time it changes
// its state is a major source of complexity and makes everything very order-dependent // its state is a major source of complexity and makes everything very order-dependent
return new ListModel<SearchResultListEntry>({ return new ListModel<SearchResultListEntry>({
fetch: async (startId: Id, count: number) => { fetch: async (lastFetchedEntity: SearchResultListEntry, count: number) => {
const startId = lastFetchedEntity == null ? GENERATED_MAX_ID : getElementId(lastFetchedEntity)
const lastResult = this._searchResult const lastResult = this._searchResult
if (lastResult !== this._searchResult) { if (lastResult !== this._searchResult) {
console.warn("got a fetch request for outdated results object, ignoring") console.warn("got a fetch request for outdated results object, ignoring")

View file

@ -5,7 +5,7 @@ import { lang } from "../../common/misc/LanguageViewModel"
import { size } from "../../common/gui/size" import { size } from "../../common/gui/size"
import { EntityClient } from "../../common/api/common/EntityClient" import { EntityClient } from "../../common/api/common/EntityClient"
import { GENERATED_MAX_ID, isSameId, listIdPart } from "../../common/api/common/utils/EntityUtils" import { isSameId, listIdPart } from "../../common/api/common/utils/EntityUtils"
import { hasCapabilityOnGroup } from "../../common/sharing/GroupUtils" import { hasCapabilityOnGroup } from "../../common/sharing/GroupUtils"
import { ShareCapability } from "../../common/api/common/TutanotaConstants" import { ShareCapability } from "../../common/api/common/TutanotaConstants"
import type { LoginController } from "../../common/api/main/LoginController" import type { LoginController } from "../../common/api/main/LoginController"
@ -83,19 +83,14 @@ export class KnowledgeBaseListView implements UpdatableSettingsViewer {
private makeListModel() { private makeListModel() {
const listModel = new ListModel<KnowledgeBaseEntry>({ const listModel = new ListModel<KnowledgeBaseEntry>({
sortCompare: (a: KnowledgeBaseEntry, b: KnowledgeBaseEntry) => { sortCompare: (a: KnowledgeBaseEntry, b: KnowledgeBaseEntry) => {
var titleA = a.title.toUpperCase() const titleA = a.title.toUpperCase()
var titleB = b.title.toUpperCase() const titleB = b.title.toUpperCase()
return titleA < titleB ? -1 : titleA > titleB ? 1 : 0 return titleA < titleB ? -1 : titleA > titleB ? 1 : 0
}, },
fetch: async (startId, count) => { fetch: async (_lastFetchedEntity, _count) => {
// fetch works like in ContactListView, because we have a custom sort order there too // load all entries at once to apply custom sort order
if (startId === GENERATED_MAX_ID) { const allEntries = await this.entityClient.loadAll(KnowledgeBaseEntryTypeRef, this.getListId())
// load all entries at once to apply custom sort order return { items: allEntries, complete: true }
const allEntries = await this.entityClient.loadAll(KnowledgeBaseEntryTypeRef, this.getListId())
return { items: allEntries, complete: true }
} else {
throw new Error("fetch knowledgeBase entry called for specific start id")
}
}, },
loadSingle: (_listId: Id, elementId: Id) => { loadSingle: (_listId: Id, elementId: Id) => {
return this.entityClient.load<KnowledgeBaseEntry>(KnowledgeBaseEntryTypeRef, [this.getListId(), elementId]) return this.entityClient.load<KnowledgeBaseEntry>(KnowledgeBaseEntryTypeRef, [this.getListId(), elementId])

View file

@ -4,7 +4,7 @@ import { showTemplateEditor } from "./TemplateEditor"
import type { EmailTemplate } from "../../common/api/entities/tutanota/TypeRefs.js" import type { EmailTemplate } from "../../common/api/entities/tutanota/TypeRefs.js"
import { EmailTemplateTypeRef } from "../../common/api/entities/tutanota/TypeRefs.js" import { EmailTemplateTypeRef } from "../../common/api/entities/tutanota/TypeRefs.js"
import { EntityClient } from "../../common/api/common/EntityClient" import { EntityClient } from "../../common/api/common/EntityClient"
import { GENERATED_MAX_ID, isSameId } from "../../common/api/common/utils/EntityUtils" import { isSameId } from "../../common/api/common/utils/EntityUtils"
import { searchInTemplates, TEMPLATE_SHORTCUT_PREFIX } from "../templates/model/TemplatePopupModel" import { searchInTemplates, TEMPLATE_SHORTCUT_PREFIX } from "../templates/model/TemplatePopupModel"
import { hasCapabilityOnGroup } from "../../common/sharing/GroupUtils" import { hasCapabilityOnGroup } from "../../common/sharing/GroupUtils"
import { ShareCapability } from "../../common/api/common/TutanotaConstants" import { ShareCapability } from "../../common/api/common/TutanotaConstants"
@ -86,15 +86,10 @@ export class TemplateListView implements UpdatableSettingsViewer {
const titleB = b.title.toUpperCase() const titleB = b.title.toUpperCase()
return titleA < titleB ? -1 : titleA > titleB ? 1 : 0 return titleA < titleB ? -1 : titleA > titleB ? 1 : 0
}, },
fetch: async (startId, count) => { fetch: async (_lastFetchedEntity, _count) => {
// fetch works like in ContactListView and KnowledgeBaseListView, because we have a custom sort order there too // load all entries at once to apply custom sort order
if (startId === GENERATED_MAX_ID) { const allEntries = await this.entityClient.loadAll(EmailTemplateTypeRef, this.templateListId())
// load all entries at once to apply custom sort order return { items: allEntries, complete: true }
const allEntries = await this.entityClient.loadAll(EmailTemplateTypeRef, this.templateListId())
return { items: allEntries, complete: true }
} else {
throw new Error("fetch template entry called for specific start id")
}
}, },
loadSingle: (_listId: Id, elementId: Id) => { loadSingle: (_listId: Id, elementId: Id) => {
return this.entityClient.load<EmailTemplate>(EmailTemplateTypeRef, [this.templateListId(), elementId]) return this.entityClient.load<EmailTemplate>(EmailTemplateTypeRef, [this.templateListId(), elementId])

View file

@ -15,7 +15,6 @@ import { SelectableRowContainer, SelectableRowSelectedSetter, setVisibility } fr
import Stream from "mithril/stream" import Stream from "mithril/stream"
import { List, ListAttrs, MultiselectMode, RenderConfig } from "../../../common/gui/base/List.js" import { List, ListAttrs, MultiselectMode, RenderConfig } from "../../../common/gui/base/List.js"
import { size } from "../../../common/gui/size.js" import { size } from "../../../common/gui/size.js"
import { GENERATED_MAX_ID } from "../../../common/api/common/utils/EntityUtils.js"
import { ListModel } from "../../../common/misc/ListModel.js" import { ListModel } from "../../../common/misc/ListModel.js"
import { compareGroupInfos } from "../../../common/api/common/utils/GroupUtils.js" import { compareGroupInfos } from "../../../common/api/common/utils/GroupUtils.js"
import { NotFoundError } from "../../../common/api/common/error/RestError.js" import { NotFoundError } from "../../../common/api/common/error/RestError.js"
@ -160,15 +159,12 @@ export class GroupListView implements UpdatableSettingsViewer {
private makeListModel(): ListModel<GroupInfo> { private makeListModel(): ListModel<GroupInfo> {
const listModel = new ListModel<GroupInfo>({ const listModel = new ListModel<GroupInfo>({
sortCompare: compareGroupInfos, sortCompare: compareGroupInfos,
fetch: async (startId) => { fetch: async (_lastFetchedEntity, _count) => {
if (startId === GENERATED_MAX_ID) { // load all entries at once to apply custom sort order
const listId = await this.listId.getAsync() const listId = await this.listId.getAsync()
const allGroupInfos = await locator.entityClient.loadAll(GroupInfoTypeRef, listId) const allGroupInfos = await locator.entityClient.loadAll(GroupInfoTypeRef, listId)
return { items: allGroupInfos, complete: true } return { items: allGroupInfos, complete: true }
} else {
throw new Error("fetch user group infos called for specific start id")
}
}, },
loadSingle: async (_listId: Id, elementId: Id) => { loadSingle: async (_listId: Id, elementId: Id) => {
const listId = await this.listId.getAsync() const listId = await this.listId.getAsync()