diff --git a/src/calendar-app/calendar/search/view/CalendarSearchListView.ts b/src/calendar-app/calendar/search/view/CalendarSearchListView.ts index 80acb106f307..4f235a80a3f3 100644 --- a/src/calendar-app/calendar/search/view/CalendarSearchListView.ts +++ b/src/calendar-app/calendar/search/view/CalendarSearchListView.ts @@ -1,7 +1,6 @@ import m, { Children, Component, Vnode } from "mithril" import { assertMainOrNode } from "../../../../common/api/common/Env" -import { downcast, TypeRef } from "@tutao/tutanota-utils" -import { ListModel } from "../../../../common/misc/ListModel.js" +import { downcast } from "@tutao/tutanota-utils" import { List, ListAttrs, MultiselectMode, RenderConfig } from "../../../../common/gui/base/List.js" import { size } from "../../../../common/gui/size.js" import { CalendarEvent } from "../../../../common/api/entities/tutanota/TypeRefs.js" @@ -12,6 +11,7 @@ import { theme } from "../../../../common/gui/theme.js" import { VirtualRow } from "../../../../common/gui/base/ListUtils.js" import { styles } from "../../../../common/gui/styles.js" import { KindaCalendarRow } from "../../gui/CalendarRow.js" +import { ListElementListModel } from "../../../../common/misc/ListElementListModel" assertMainOrNode() @@ -24,14 +24,14 @@ export class CalendarSearchResultListEntry { } export interface CalendarSearchListViewAttrs { - listModel: ListModel + listModel: ListElementListModel onSingleSelection: (item: CalendarSearchResultListEntry) => unknown isFreeAccount: boolean cancelCallback: () => unknown | null } export class CalendarSearchListView implements Component { - private listModel: ListModel + private listModel: ListElementListModel constructor({ attrs }: Vnode) { this.listModel = attrs.listModel diff --git a/src/calendar-app/calendar/search/view/CalendarSearchViewModel.ts b/src/calendar-app/calendar/search/view/CalendarSearchViewModel.ts index a538c8da61b5..479fb53c6793 100644 --- a/src/calendar-app/calendar/search/view/CalendarSearchViewModel.ts +++ b/src/calendar-app/calendar/search/view/CalendarSearchViewModel.ts @@ -1,4 +1,3 @@ -import { ListModel } from "../../../../common/misc/ListModel.js" import { CalendarSearchResultListEntry } from "./CalendarSearchListView.js" import { SearchRestriction, SearchResult } from "../../../../common/api/worker/search/SearchTypes.js" import { EntityEventsListener, EventController } from "../../../../common/api/main/EventController.js" @@ -47,6 +46,7 @@ import { SearchRouter } from "../../../../common/search/view/SearchRouter.js" import { locator } from "../../../../common/api/main/CommonLocator.js" import { CalendarEventsRepository } from "../../../../common/calendar/date/CalendarEventsRepository" import { getClientOnlyCalendars } from "../../gui/CalendarGuiUtils" +import { ListElementListModel } from "../../../../common/misc/ListElementListModel" const SEARCH_PAGE_SIZE = 100 @@ -56,8 +56,8 @@ export enum PaidFunctionResult { } export class CalendarSearchViewModel { - private _listModel: ListModel - get listModel(): ListModel { + private _listModel: ListElementListModel + get listModel(): ListElementListModel { return this._listModel } @@ -539,19 +539,19 @@ export class CalendarSearchViewModel { this.updateUi() } - private createList(): ListModel { + private createList(): ListElementListModel { // since we recreate the list every time we set a new result object, // we bind the value of result for the lifetime of this list model // at this point // 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 - return new ListModel({ + return new ListElementListModel({ fetch: async (lastFetchedEntity: CalendarSearchResultListEntry, count: number) => { const startId = lastFetchedEntity == null ? GENERATED_MAX_ID : getElementId(lastFetchedEntity) const lastResult = this.searchResult if (lastResult !== this.searchResult) { console.warn("got a fetch request for outdated results object, ignoring") - // this.searchResults was reassigned, we'll create a new ListModel soon + // this.searchResults was reassigned, we'll create a new ListElementListModel soon return { items: [], complete: true } } diff --git a/src/common/gui/base/ListUtils.ts b/src/common/gui/base/ListUtils.ts index 72aed744c471..76a03436b52c 100644 --- a/src/common/gui/base/ListUtils.ts +++ b/src/common/gui/base/ListUtils.ts @@ -1,11 +1,11 @@ import { ListElement } from "../../api/common/utils/EntityUtils.js" -import { ListModel } from "../../misc/ListModel.js" import { Shortcut } from "../../misc/KeyManager.js" import { Keys } from "../../api/common/TutanotaConstants.js" import { mapLazily } from "@tutao/tutanota-utils" import { ListState, MultiselectMode } from "./List.js" import { Children } from "mithril" import { isBrowser } from "../../api/common/Env.js" +import { ListElementListModel } from "../../misc/ListElementListModel" export const ACTION_DISTANCE = 150 export const PageSize = 100 @@ -29,7 +29,7 @@ export interface ListFetchResult { complete: boolean } -export type ListSelectionCallbacks = Pick, "selectPrevious" | "selectNext" | "areAllSelected" | "selectAll" | "selectNone"> +export type ListSelectionCallbacks = Pick, "selectPrevious" | "selectNext" | "areAllSelected" | "selectAll" | "selectNone"> export function listSelectionKeyboardShortcuts(multiselectMode: MultiselectMode, callbacks: () => ListSelectionCallbacks | null): Array { const multiselectionEnabled = multiselectMode == MultiselectMode.Enabled ? () => true : () => false diff --git a/src/common/misc/ListElementListModel.ts b/src/common/misc/ListElementListModel.ts new file mode 100644 index 000000000000..c11f7a28a6d4 --- /dev/null +++ b/src/common/misc/ListElementListModel.ts @@ -0,0 +1,178 @@ +import { ListFilter, ListModel, ListModelConfig } from "./ListModel" +import { getElementId, isSameId, ListElement } from "../api/common/utils/EntityUtils" +import { OperationType } from "../api/common/TutanotaConstants" +import Stream from "mithril/stream" +import { ListLoadingState, ListState } from "../gui/base/List" + +export type ListElementListModelConfig = Omit, "getItemId" | "isSameId"> + +export class ListElementListModel { + private readonly listModel: ListModel + private readonly config: ListModelConfig + + get state(): ListState { + return this.listModel.state + } + + get differentItemsSelected(): Stream> { + return this.listModel.differentItemsSelected + } + + get stateStream(): Stream> { + return this.listModel.stateStream + } + + constructor(config: ListElementListModelConfig) { + this.config = Object.assign({}, config, { + isSameId, + getItemId: getElementId, + }) + this.listModel = new ListModel(this.config) + } + + async entityEventReceived(listId: Id, elementId: Id, operation: OperationType): Promise { + if (operation === OperationType.CREATE || operation === OperationType.UPDATE) { + // load the element without range checks for now + const entity = await this.config.loadSingle(listId, elementId) + if (!entity) { + return + } + + // Wait for any pending loading + return this.listModel.waitLoad(() => { + if (operation === OperationType.CREATE) { + if (this.canCreateEntity(entity)) { + this.listModel.insertLoadedItem(entity) + } + } else if (operation === OperationType.UPDATE) { + this.listModel.updateLoadedItem(entity) + } + }) + } else if (operation === OperationType.DELETE) { + // await this.swipeHandler?.animating + await this.listModel.deleteLoadedItem(elementId) + } + } + + private canCreateEntity(entity: ElementType): boolean { + if (this.state.loadingStatus !== ListLoadingState.Done) { + return false + } + + // new element is in the loaded range or newer than the first element + const lastElement = this.listModel.getLastItem() + return lastElement != null && this.config.sortCompare(entity, lastElement) < 0 + } + + async loadAndSelect( + itemId: Id, + shouldStop: () => boolean, + finder: (a: ElementType) => boolean = (item) => this.config.isSameId(this.config.getItemId(item), itemId), + ): Promise { + return this.listModel.loadAndSelect(itemId, shouldStop, finder) + } + + isItemSelected(itemId: Id): boolean { + return this.listModel.isItemSelected(itemId) + } + + enterMultiselect() { + return this.listModel.enterMultiselect() + } + + stopLoading(): void { + return this.listModel.stopLoading() + } + + isEmptyAndDone(): boolean { + return this.listModel.isEmptyAndDone() + } + + isSelectionEmpty(): boolean { + return this.listModel.isSelectionEmpty() + } + + getUnfilteredAsArray(): Array { + return this.listModel.getUnfilteredAsArray() + } + + sort() { + return this.listModel.sort() + } + + async loadMore() { + return this.listModel.loadMore() + } + + async loadAll() { + return this.listModel.loadAll() + } + + async retryLoading() { + return this.listModel.retryLoading() + } + + onSingleSelection(item: ElementType) { + return this.listModel.onSingleSelection(item) + } + + onSingleInclusiveSelection(item: ElementType, clearSelectionOnMultiSelectStart?: boolean) { + return this.listModel.onSingleInclusiveSelection(item, clearSelectionOnMultiSelectStart) + } + + onSingleExclusiveSelection(item: ElementType) { + return this.listModel.onSingleExclusiveSelection(item) + } + + selectRangeTowards(item: ElementType) { + return this.listModel.selectRangeTowards(item) + } + + areAllSelected(): boolean { + return this.listModel.areAllSelected() + } + + selectNone() { + return this.listModel.selectNone() + } + + selectAll() { + return this.listModel.selectAll() + } + + selectPrevious(multiselect: boolean) { + return this.listModel.selectPrevious(multiselect) + } + + selectNext(multiselect: boolean) { + return this.listModel.selectNext(multiselect) + } + + cancelLoadAll() { + return this.listModel.cancelLoadAll() + } + + async loadInitial() { + return this.listModel.loadInitial() + } + + reapplyFilter() { + return this.listModel.reapplyFilter() + } + + setFilter(filter: ListFilter | null) { + return this.listModel.setFilter(filter) + } + + getSelectedAsArray(): Array { + return this.listModel.getSelectedAsArray() + } + + isLoadedCompletely(): boolean { + return this.listModel.isLoadedCompletely() + } + + updateLoadingStatus(status: ListLoadingState) { + return this.listModel.updateLoadingStatus(status) + } +} diff --git a/src/common/misc/ListModel.ts b/src/common/misc/ListModel.ts index 8087957302b9..e63a383c3fc1 100644 --- a/src/common/misc/ListModel.ts +++ b/src/common/misc/ListModel.ts @@ -1,7 +1,4 @@ -import { elementIdPart, getElementId, isSameId, ListElement } from "../api/common/utils/EntityUtils.js" import { ListLoadingState, ListState } from "../gui/base/List.js" - -import { OperationType } from "../api/common/TutanotaConstants.js" import { assertNonNull, binarySearch, @@ -25,72 +22,86 @@ import { ListFetchResult, PageSize } from "../gui/base/ListUtils.js" import { isOfflineError } from "../api/common/utils/ErrorUtils.js" import { ListAutoSelectBehavior } from "./DeviceConfig.js" -export type ListModelConfig = { +export type ListModelConfig = { /** - * 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 items than requested, e.g. if all items are available on first fetch. */ - fetch(lastFetchedEntity: ListElementType | null | undefined, count: number): Promise> + fetch(lastFetchedItem: ItemType | null | undefined, count: number): Promise> /** - * Returns null if the given element could not be loaded + * Returns null if the given item could not be loaded */ - loadSingle(listId: Id, elementId: Id): Promise + loadSingle(listId: IdType, itemId: IdType): Promise - sortCompare(entity1: ListElementType, entity2: ListElementType): number + /** + * Compare the items + * @return 0 if equal, less than 0 if less and greater than 0 if greater + */ + sortCompare(item1: ItemType, item2: ItemType): number + + /** + * @return the ID of the item + */ + getItemId(item: ItemType): IdType + + /** + * @return true if the IDs are the same + */ + isSameId(id1: IdType, id2: IdType): boolean autoSelectBehavior: () => ListAutoSelectBehavior } -export type ListFilter = (item: ElementType) => boolean +export type ListFilter = (item: ItemType) => boolean -type PrivateListState = Omit, "items" | "activeIndex"> & { - unfilteredItems: ElementType[] - filteredItems: ElementType[] - activeElement: ElementType | null +type PrivateListState = Omit, "items" | "activeIndex"> & { + unfilteredItems: ItemType[] + filteredItems: ItemType[] + activeItem: ItemType | null } /** ListModel that does the state upkeep for the List, including loading state, loaded items, selection and filters*/ -export class ListModel { - constructor(private readonly config: ListModelConfig) {} +export class ListModel { + constructor(private readonly config: ListModelConfig) {} private loadState: "created" | "initialized" = "created" private loading: Promise = Promise.resolve() - private filter: ListFilter | null = null - private rangeSelectionAnchorElement: ElementType | null = null + private filter: ListFilter | null = null + private rangeSelectionAnchorItem: ItemType | null = null - get state(): ListState { + get state(): ListState { return this.stateStream() } - private get rawState(): PrivateListState { + private get rawState(): PrivateListState { return this.rawStateStream() } - private defaultRawStateStream: PrivateListState = { + private defaultRawStateStream: PrivateListState = { unfilteredItems: [], filteredItems: [], inMultiselect: false, loadingStatus: ListLoadingState.Idle, loadingAll: false, selectedItems: new Set(), - activeElement: null, + activeItem: null, } - private rawStateStream: Stream> = stream(this.defaultRawStateStream) + private rawStateStream: Stream> = stream(this.defaultRawStateStream) - readonly stateStream: Stream> = this.rawStateStream.map((state) => { - const activeElement = state.activeElement - const foundIndex = activeElement ? binarySearch(state.filteredItems, activeElement, (l, r) => this.config.sortCompare(l, r)) : -1 + readonly stateStream: Stream> = this.rawStateStream.map((state) => { + const activeItem = state.activeItem + const foundIndex = activeItem ? binarySearch(state.filteredItems, activeItem, (l, r) => this.config.sortCompare(l, r)) : -1 const activeIndex = foundIndex < 0 ? null : foundIndex return { ...state, items: state.filteredItems, activeIndex } }) - readonly differentItemsSelected: Stream> = Stream.scan( - (acc: ReadonlySet, state: ListState) => { - const newSelectedIds = setMap(state.selectedItems, getElementId) - const oldSelectedIds = setMap(acc, getElementId) + readonly differentItemsSelected: Stream> = Stream.scan( + (acc: ReadonlySet, state: ListState) => { + const newSelectedIds = setMap(state.selectedItems, (item) => this.config.getItemId(item)) + const oldSelectedIds = setMap(acc, (item) => this.config.getItemId(item)) if (setEquals(oldSelectedIds, newSelectedIds)) { // Stream.scan type definitions does not take it into account - return Stream.SKIP as unknown as ReadonlySet + return Stream.SKIP as unknown as ReadonlySet } else { return state.selectedItems } @@ -99,7 +110,7 @@ export class ListModel { this.stateStream, ) - private updateState(newStatePart: Partial>) { + private updateState(newStatePart: Partial>) { this.rawStateStream({ ...this.rawState, ...newStatePart }) } @@ -175,11 +186,11 @@ export class ListModel { return this.loading } - private applyFilter(newItems: ReadonlyArray): Array { + private applyFilter(newItems: ReadonlyArray): Array { return newItems.filter(this.filter ?? (() => true)) } - setFilter(filter: ListFilter | null) { + setFilter(filter: ListFilter | null) { this.filter = filter this.reapplyFilter() } @@ -192,136 +203,16 @@ export class ListModel { this.updateState({ filteredItems: newFilteredItems, selectedItems: newSelectedItems }) } - async entityEventReceived(listId: Id, elementId: Id, operation: OperationType): Promise { - if (operation === OperationType.CREATE || operation === OperationType.UPDATE) { - // load the element without range checks for now - const entity = await this.config.loadSingle(listId, elementId) - if (!entity) { - return - } - - // Wait for any pending loading - return settledThen(this.loading, () => { - if (operation === OperationType.CREATE) { - if ( - this.rawState.loadingStatus === ListLoadingState.Done || - // new element is in the loaded range or newer than the first element - (this.rawState.unfilteredItems.length > 0 && this.config.sortCompare(entity, lastThrow(this.rawState.unfilteredItems)) < 0) - ) { - this.addToLoadedEntities(entity) - } - } else if (operation === OperationType.UPDATE) { - this.updateLoadedEntity(entity) - } - }) - } else if (operation === OperationType.DELETE) { - // await this.swipeHandler?.animating - await this.deleteLoadedEntity(elementId) - } - } - - private addToLoadedEntities(entity: ElementType) { - const id = getElementId(entity) - if (this.rawState.unfilteredItems.some((item) => getElementId(item) === id)) { - return - } - - // can we do something like binary search? - const unfilteredItems = this.rawState.unfilteredItems.concat(entity).sort(this.config.sortCompare) - const filteredItems = this.rawState.filteredItems.concat(this.applyFilter([entity])).sort(this.config.sortCompare) - this.updateState({ filteredItems, unfilteredItems }) - } - - 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 - // 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 - // by the element id, ignoring the list id - - // update unfiltered list: find the position, take out the old item and put the updated one - const positionToUpdateUnfiltered = this.rawState.unfilteredItems.findIndex((item) => isSameId(elementIdPart(item._id), elementIdPart(entity._id))) - const unfilteredItems = this.rawState.unfilteredItems.slice() - if (positionToUpdateUnfiltered >= 0) { - unfilteredItems.splice(positionToUpdateUnfiltered, 1, entity) - unfilteredItems.sort(this.config.sortCompare) - } - - // update filtered list & selected items - const positionToUpdateFiltered = this.rawState.filteredItems.findIndex((item) => isSameId(elementIdPart(item._id), elementIdPart(entity._id))) - const filteredItems = this.rawState.filteredItems.slice() - const selectedItems = new Set(this.rawState.selectedItems) - if (positionToUpdateFiltered >= 0) { - const [oldItem] = filteredItems.splice(positionToUpdateFiltered, 1, entity) - filteredItems.sort(this.config.sortCompare) - if (selectedItems.delete(oldItem)) { - selectedItems.add(entity) - } - } - - // keep active element up-to-date - const activeElementUpdated = this.rawState.activeElement != null && isSameId(elementIdPart(this.rawState.activeElement._id), elementIdPart(entity._id)) - const newActiveElement = this.rawState.activeElement - - if (positionToUpdateUnfiltered !== -1 || positionToUpdateFiltered !== -1 || activeElementUpdated) { - this.updateState({ unfilteredItems, filteredItems, selectedItems, activeElement: newActiveElement }) - } - - // keep anchor up-to-date - if (this.rangeSelectionAnchorElement != null && isSameId(this.rangeSelectionAnchorElement._id, entity._id)) { - this.rangeSelectionAnchorElement = entity - } + onSingleSelection(item: ItemType): void { + this.updateState({ selectedItems: new Set([item]), inMultiselect: false, activeItem: item }) + this.rangeSelectionAnchorItem = item } - private deleteLoadedEntity(elementId: Id): Promise { - return settledThen(this.loading, () => { - const entity = this.rawState.filteredItems.find((e) => getElementId(e) === elementId) - - const selectedItems = new Set(this.rawState.selectedItems) - - let newActiveElement - - if (entity) { - const wasEntityRemoved = selectedItems.delete(entity) - - if (this.rawState.filteredItems.length > 1) { - const desiredBehavior = this.config.autoSelectBehavior?.() ?? null - if (wasEntityRemoved) { - if (desiredBehavior === ListAutoSelectBehavior.NONE || this.state.inMultiselect) { - selectedItems.clear() - } else if (desiredBehavior === ListAutoSelectBehavior.NEWER) { - newActiveElement = this.getPreviousItem(entity) - } else { - newActiveElement = entity === last(this.state.items) ? this.getPreviousItem(entity) : this.getNextItem(entity, null) - } - } - - if (newActiveElement) { - selectedItems.add(newActiveElement) - } else { - newActiveElement = this.rawState.activeElement - } - } - - const filteredItems = this.rawState.filteredItems.slice() - remove(filteredItems, entity) - const unfilteredItems = this.rawState.unfilteredItems.slice() - remove(unfilteredItems, entity) - this.updateState({ filteredItems, selectedItems, unfilteredItems, activeElement: newActiveElement }) - } - }) - } - - onSingleSelection(item: ElementType): void { - this.updateState({ selectedItems: new Set([item]), inMultiselect: false, activeElement: item }) - this.rangeSelectionAnchorElement = item - } - - /** An element was added to the selection. If multiselect was not on, discard previous single selection and only added selected item to the selection. */ - onSingleExclusiveSelection(item: ElementType): void { + /** An item was added to the selection. If multiselect was not on, discard previous single selection and only added selected item to the selection. */ + onSingleExclusiveSelection(item: ItemType): void { if (!this.rawState.inMultiselect) { - this.updateState({ selectedItems: new Set([item]), inMultiselect: true, activeElement: item }) - this.rangeSelectionAnchorElement = item + this.updateState({ selectedItems: new Set([item]), inMultiselect: true, activeItem: item }) + this.rangeSelectionAnchorItem = item } else { const selectedItems = new Set(this.state.selectedItems) if (selectedItems.has(item)) { @@ -330,17 +221,17 @@ export class ListModel { selectedItems.add(item) } if (selectedItems.size === 0) { - this.updateState({ selectedItems, inMultiselect: false, activeElement: null }) - this.rangeSelectionAnchorElement = null + this.updateState({ selectedItems, inMultiselect: false, activeItem: null }) + this.rangeSelectionAnchorItem = null } else { - this.updateState({ selectedItems, inMultiselect: true, activeElement: item }) - this.rangeSelectionAnchorElement = item + this.updateState({ selectedItems, inMultiselect: true, activeItem: item }) + this.rangeSelectionAnchorItem = item } } } - /** An element was added to the selection. If multiselect was not on, add previous single selection and newly added selected item to the selection. */ - onSingleInclusiveSelection(item: ElementType, clearSelectionOnMultiSelectStart?: boolean): void { + /** An item was added to the selection. If multiselect was not on, add previous single selection and newly added selected item to the selection. */ + onSingleInclusiveSelection(item: ItemType, clearSelectionOnMultiSelectStart?: boolean): void { // If it isn't in MultiSelect, we discard all previous items // and start a new set of selected items in MultiSelect mode // we do it only if the user is on singleColumnMode, because @@ -358,21 +249,21 @@ export class ListModel { } if (selectedItems.size === 0) { - this.updateState({ selectedItems, inMultiselect: false, activeElement: null }) - this.rangeSelectionAnchorElement = null + this.updateState({ selectedItems, inMultiselect: false, activeItem: null }) + this.rangeSelectionAnchorItem = null } else { - this.updateState({ selectedItems, inMultiselect: true, activeElement: item }) - this.rangeSelectionAnchorElement = item + this.updateState({ selectedItems, inMultiselect: true, activeItem: item }) + this.rangeSelectionAnchorItem = item } } async loadAndSelect( - itemId: Id, + itemId: IdType, shouldStop: () => boolean, - finder: (a: ElementType) => boolean = (item) => getElementId(item) === itemId, - ): Promise { + finder: (a: ItemType) => boolean = (item) => this.config.isSameId(this.config.getItemId(item), itemId), + ): Promise { await this.waitUtilInit() - let foundItem: ElementType | undefined = undefined + let foundItem: ItemType | undefined = undefined while ( // if we did find the target mail, stop // make sure to call this before shouldStop or we might stop before trying to find an item @@ -392,12 +283,12 @@ export class ListModel { return foundItem ?? null } - selectRangeTowards(item: ElementType): void { + selectRangeTowards(item: ItemType): void { const selectedItems = new Set(this.state.selectedItems) if (selectedItems.size === 0) { selectedItems.add(item) } else { - // we are trying to find the element that's closest to the click one + // we are trying to find the item that's closest to the clicked one // and after that we will select everything between the closest and the clicked one const clickedItemIndex: number = this.state.items.indexOf(item) @@ -413,7 +304,7 @@ export class ListModel { } assertNonNull(nearestSelectedIndex) - const itemsToAddToSelection: ElementType[] = [] + const itemsToAddToSelection: ItemType[] = [] if (nearestSelectedIndex < clickedItemIndex) { for (let i = nearestSelectedIndex + 1; i <= clickedItemIndex; i++) { @@ -427,12 +318,12 @@ export class ListModel { setAddAll(selectedItems, itemsToAddToSelection) } - this.updateState({ selectedItems, inMultiselect: true, activeElement: item }) - this.rangeSelectionAnchorElement = item + this.updateState({ selectedItems, inMultiselect: true, activeItem: item }) + this.rangeSelectionAnchorItem = item } selectPrevious(multiselect: boolean) { - const oldActiveItem = this.rawState.activeElement + const oldActiveItem = this.rawState.activeItem const newActiveItem = this.getPreviousItem(oldActiveItem) if (newActiveItem != null) { @@ -440,11 +331,11 @@ export class ListModel { this.onSingleSelection(newActiveItem) } else { const selectedItems = new Set(this.state.selectedItems) - this.rangeSelectionAnchorElement = this.rangeSelectionAnchorElement ?? first(this.state.items) - if (!this.rangeSelectionAnchorElement) return + this.rangeSelectionAnchorItem = this.rangeSelectionAnchorItem ?? first(this.state.items) + if (!this.rangeSelectionAnchorItem) return const previousActiveIndex = this.state.activeIndex ?? 0 - const towardsAnchor = this.config.sortCompare(oldActiveItem ?? getFirstOrThrow(this.state.items), this.rangeSelectionAnchorElement) > 0 + const towardsAnchor = this.config.sortCompare(oldActiveItem ?? getFirstOrThrow(this.state.items), this.rangeSelectionAnchorItem) > 0 if (towardsAnchor) { // remove selectedItems.delete(this.state.items[previousActiveIndex]) @@ -453,19 +344,19 @@ export class ListModel { selectedItems.add(newActiveItem) } - this.updateState({ activeElement: newActiveItem, selectedItems, inMultiselect: true }) + this.updateState({ activeItem: newActiveItem, selectedItems, inMultiselect: true }) } } } - private getPreviousItem(oldActiveItem: ElementType | null) { + private getPreviousItem(oldActiveItem: ItemType | null) { return oldActiveItem == null ? first(this.state.items) - : findLast(this.state.items, (el) => this.config.sortCompare(el, oldActiveItem) < 0) ?? first(this.state.items) + : findLast(this.state.items, (item) => this.config.sortCompare(item, oldActiveItem) < 0) ?? first(this.state.items) } selectNext(multiselect: boolean) { - const oldActiveItem = this.rawState.activeElement + const oldActiveItem = this.rawState.activeItem const lastItem = last(this.state.items) const newActiveItem = this.getNextItem(oldActiveItem, lastItem) @@ -474,27 +365,27 @@ export class ListModel { this.onSingleSelection(newActiveItem) } else { const selectedItems = new Set(this.state.selectedItems) - this.rangeSelectionAnchorElement = this.rangeSelectionAnchorElement ?? first(this.state.items) - if (!this.rangeSelectionAnchorElement) return + this.rangeSelectionAnchorItem = this.rangeSelectionAnchorItem ?? first(this.state.items) + if (!this.rangeSelectionAnchorItem) return const previousActiveIndex = this.state.activeIndex ?? 0 - const towardsAnchor = this.config.sortCompare(oldActiveItem ?? getFirstOrThrow(this.state.items), this.rangeSelectionAnchorElement) < 0 + const towardsAnchor = this.config.sortCompare(oldActiveItem ?? getFirstOrThrow(this.state.items), this.rangeSelectionAnchorItem) < 0 if (towardsAnchor) { selectedItems.delete(this.state.items[previousActiveIndex]) } else { selectedItems.add(newActiveItem) } - this.updateState({ selectedItems, inMultiselect: true, activeElement: newActiveItem }) + this.updateState({ selectedItems, inMultiselect: true, activeItem: newActiveItem }) } } } - private getNextItem(oldActiveItem: ElementType | null, lastItem: ElementType | null | undefined) { + private getNextItem(oldActiveItem: ItemType | null, lastItem: ItemType | null | undefined) { return oldActiveItem == null ? first(this.state.items) : lastItem && this.config.sortCompare(lastItem, oldActiveItem) <= 0 ? lastItem - : this.state.items.find((el) => this.config.sortCompare(el, oldActiveItem) > 0) ?? first(this.state.items) + : this.state.items.find((item) => this.config.sortCompare(item, oldActiveItem) > 0) ?? first(this.state.items) } areAllSelected(): boolean { @@ -502,36 +393,36 @@ export class ListModel { } selectAll() { - this.updateState({ selectedItems: new Set(this.state.items), activeElement: null, inMultiselect: true }) - this.rangeSelectionAnchorElement = null + this.updateState({ selectedItems: new Set(this.state.items), activeItem: null, inMultiselect: true }) + this.rangeSelectionAnchorItem = null } selectNone() { - this.rangeSelectionAnchorElement = null - this.updateState({ selectedItems: new Set(), inMultiselect: false }) + this.rangeSelectionAnchorItem = null + this.updateState({ selectedItems: new Set(), inMultiselect: false }) } - isItemSelected(itemId: Id): boolean { - return findBy(this.state.selectedItems, (item: ElementType) => getElementId(item) === itemId) != null + isItemSelected(itemId: IdType): boolean { + return findBy(this.state.selectedItems, (item: ItemType) => this.config.isSameId(this.config.getItemId(item), itemId)) != null } - readonly getSelectedAsArray: () => Array = memoizedWithHiddenArgument( + readonly getSelectedAsArray: () => Array = memoizedWithHiddenArgument( () => this.state, - (state: ListState) => [...state.selectedItems], + (state: ListState) => [...state.selectedItems], ) readonly isSelectionEmpty: () => boolean = memoizedWithHiddenArgument( () => this.state, - (state: ListState) => state.selectedItems.size === 0, + (state: ListState) => state.selectedItems.size === 0, ) - readonly getUnfilteredAsArray: () => Array = memoizedWithHiddenArgument( + readonly getUnfilteredAsArray: () => Array = memoizedWithHiddenArgument( () => this.rawState, - (state: PrivateListState) => [...state.unfilteredItems], + (state: PrivateListState) => [...state.unfilteredItems], ) enterMultiselect() { - // avoid having the viewed element as a preselected one which might be confusing. + // avoid having the viewed item as a preselected one which might be confusing. this.selectNone() this.updateState({ inMultiselect: true }) } @@ -577,9 +468,118 @@ export class ListModel { this.updateState({ loadingStatus: ListLoadingState.ConnectionLost }) } } + + waitLoad(what: () => any): Promise { + return settledThen(this.loading, what) + } + + insertLoadedItem(item: ItemType) { + if (this.rawState.unfilteredItems.some((unfilteredItem) => this.hasSameId(unfilteredItem, item))) { + return + } + + // can we do something like binary search? + const unfilteredItems = this.rawState.unfilteredItems.concat(item).sort(this.config.sortCompare) + const filteredItems = this.rawState.filteredItems.concat(this.applyFilter([item])).sort(this.config.sortCompare) + this.updateState({ filteredItems, unfilteredItems }) + } + + updateLoadedItem(item: ItemType) { + // We cannot use binary search here because the sort order of items can change based on an entity update, and we need to find the position of the + // old entity by id in order to remove it. + + // Since every item id is unique and there's no scenario where the same item appears twice but in different lists, we can safely sort just + // by the item id, ignoring the list id + + // update unfiltered list: find the position, take out the old item and put the updated one + const positionToUpdateUnfiltered = this.rawState.unfilteredItems.findIndex((unfilteredItem) => this.hasSameId(unfilteredItem, item)) + const unfilteredItems = this.rawState.unfilteredItems.slice() + if (positionToUpdateUnfiltered >= 0) { + unfilteredItems.splice(positionToUpdateUnfiltered, 1, item) + unfilteredItems.sort(this.config.sortCompare) + } + + // update filtered list & selected items + const positionToUpdateFiltered = this.rawState.filteredItems.findIndex((filteredItem) => this.hasSameId(filteredItem, item)) + const filteredItems = this.rawState.filteredItems.slice() + const selectedItems = new Set(this.rawState.selectedItems) + if (positionToUpdateFiltered >= 0) { + const [oldItem] = filteredItems.splice(positionToUpdateFiltered, 1, item) + filteredItems.sort(this.config.sortCompare) + if (selectedItems.delete(oldItem)) { + selectedItems.add(item) + } + } + + // keep active item up-to-date + const activeItemUpdated = this.rawState.activeItem != null && this.hasSameId(this.rawState.activeItem, item) + const newActiveItem = this.rawState.activeItem + + if (positionToUpdateUnfiltered !== -1 || positionToUpdateFiltered !== -1 || activeItemUpdated) { + this.updateState({ unfilteredItems, filteredItems, selectedItems, activeItem: newActiveItem }) + } + + // keep anchor up-to-date + if (this.rangeSelectionAnchorItem != null && this.hasSameId(this.rangeSelectionAnchorItem, item)) { + this.rangeSelectionAnchorItem = item + } + } + + deleteLoadedItem(itemId: IdType): Promise { + return settledThen(this.loading, () => { + const item = this.rawState.filteredItems.find((e) => this.config.isSameId(this.config.getItemId(e), itemId)) + + const selectedItems = new Set(this.rawState.selectedItems) + + let newActiveItem + + if (item) { + const wasRemoved = selectedItems.delete(item) + + if (this.rawState.filteredItems.length > 1) { + const desiredBehavior = this.config.autoSelectBehavior?.() ?? null + if (wasRemoved) { + if (desiredBehavior === ListAutoSelectBehavior.NONE || this.state.inMultiselect) { + selectedItems.clear() + } else if (desiredBehavior === ListAutoSelectBehavior.NEWER) { + newActiveItem = this.getPreviousItem(item) + } else { + newActiveItem = item === last(this.state.items) ? this.getPreviousItem(item) : this.getNextItem(item, null) + } + } + + if (newActiveItem) { + selectedItems.add(newActiveItem) + } else { + newActiveItem = this.rawState.activeItem + } + } + + const filteredItems = this.rawState.filteredItems.slice() + remove(filteredItems, item) + const unfilteredItems = this.rawState.unfilteredItems.slice() + remove(unfilteredItems, item) + this.updateState({ filteredItems, selectedItems, unfilteredItems, activeItem: newActiveItem }) + } + }) + } + + getLastItem(): ItemType | null { + if (this.rawState.unfilteredItems.length > 0) { + return lastThrow(this.rawState.unfilteredItems) + } else { + return null + } + } + + private hasSameId(item1: ItemType, item2: ItemType): boolean { + const id1 = this.config.getItemId(item1) + const id2 = this.config.getItemId(item2) + return this.config.isSameId(id1, id2) + } } -export function selectionAttrsForList(listModel: Pick, "areAllSelected" | "selectNone" | "selectAll"> | null) { +export function selectionAttrsForList(listModel: Pick, "areAllSelected" | "selectNone" | "selectAll"> | null) { return { selected: listModel?.areAllSelected() ?? false, selectNone: () => listModel?.selectNone(), diff --git a/src/common/settings/UserListView.ts b/src/common/settings/UserListView.ts index d0eaa95dc184..aee25825e228 100644 --- a/src/common/settings/UserListView.ts +++ b/src/common/settings/UserListView.ts @@ -18,7 +18,6 @@ import { locator } from "../api/main/CommonLocator.js" import Stream from "mithril/stream" import * as AddUserDialog from "./AddUserDialog.js" import { SelectableRowContainer, SelectableRowSelectedSetter, setVisibility } from "../gui/SelectableRowContainer.js" -import { ListModel } from "../misc/ListModel.js" import { List, ListAttrs, MultiselectMode, RenderConfig } from "../gui/base/List.js" import { listSelectionKeyboardShortcuts, VirtualRow } from "../gui/base/ListUtils.js" import ColumnEmptyMessageBox from "../gui/base/ColumnEmptyMessageBox.js" @@ -31,6 +30,7 @@ import { keyManager } from "../misc/KeyManager.js" import { EntityUpdateData, isUpdateFor, isUpdateForTypeRef } from "../api/common/utils/EntityUpdateUtils.js" import { ListAutoSelectBehavior } from "../misc/DeviceConfig.js" import { UpdatableSettingsViewer } from "./Interfaces.js" +import { ListElementListModel } from "../misc/ListElementListModel" assertMainOrNode() @@ -41,7 +41,7 @@ assertMainOrNode() */ export class UserListView implements UpdatableSettingsViewer { private searchQuery: string = "" - private listModel: ListModel + private listModel: ListElementListModel private readonly renderConfig: RenderConfig = { createElement: (dom) => { const row = new UserRow((groupInfo) => this.isAdmin(groupInfo)) @@ -207,8 +207,8 @@ export class UserListView implements UpdatableSettingsViewer { } } - private makeListModel(): ListModel { - const listModel = new ListModel({ + private makeListModel(): ListElementListModel { + const listModel = new ListElementListModel({ sortCompare: compareGroupInfos, fetch: async (_lastFetchedEntity) => { await this.loadAdmins() diff --git a/src/mail-app/contacts/view/ContactListViewModel.ts b/src/mail-app/contacts/view/ContactListViewModel.ts index 5ff46b29ffb4..e4154498b7ef 100644 --- a/src/mail-app/contacts/view/ContactListViewModel.ts +++ b/src/mail-app/contacts/view/ContactListViewModel.ts @@ -1,4 +1,4 @@ -import { ListModel } from "../../../common/misc/ListModel.js" +import { ListElementListModel } from "../../../common/misc/ListElementListModel.js" import { Contact, ContactListEntry, @@ -77,12 +77,12 @@ export class ContactListViewModel { await this.contactModel.getLoadedContactListInfos() }) - get listModel(): ListModel | null { + get listModel(): ListElementListModel | null { return this.selectedContactList ? this._listModel(this.selectedContactList) : null } private readonly _listModel = memoized((listId: Id) => { - const newListModel = new ListModel({ + const newListModel = new ListElementListModel({ fetch: async () => { const items = await this.getRecipientsForList(listId) return { items, complete: true } @@ -148,7 +148,10 @@ export class ContactListViewModel { if (!this.listModel?.state.inMultiselect) { const recipient = this.getSelectedContactListEntries() if (recipient && recipient.length === 1) { - this.router.routeTo(`/contactlist/:listId/:itemId`, { listId: this.selectedContactList, itemId: recipient[0]._id[1] }) + this.router.routeTo(`/contactlist/:listId/:itemId`, { + listId: this.selectedContactList, + itemId: recipient[0]._id[1], + }) return } } diff --git a/src/mail-app/contacts/view/ContactViewModel.ts b/src/mail-app/contacts/view/ContactViewModel.ts index 1090ab95796a..1bbb6efe6400 100644 --- a/src/mail-app/contacts/view/ContactViewModel.ts +++ b/src/mail-app/contacts/view/ContactViewModel.ts @@ -1,7 +1,7 @@ import { ContactModel } from "../../../common/contactsFunctionality/ContactModel.js" import { EntityClient } from "../../../common/api/common/EntityClient.js" import { EntityEventsListener, EventController } from "../../../common/api/main/EventController.js" -import { ListModel } from "../../../common/misc/ListModel.js" +import { ListElementListModel } from "../../../common/misc/ListElementListModel.js" import { Contact, ContactTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js" import { compareContacts } from "./ContactGuiUtils.js" import { ListState } from "../../../common/gui/base/List.js" @@ -28,7 +28,7 @@ export class ContactViewModel { private readonly updateUi: () => unknown, ) {} - readonly listModel: ListModel = new ListModel({ + readonly listModel: ListElementListModel = new ListElementListModel({ fetch: async () => { const items = await this.entityClient.loadAll(ContactTypeRef, this.contactListId) return { items, complete: true } diff --git a/src/mail-app/mail/view/MailListView.ts b/src/mail-app/mail/view/MailListView.ts index 3d4a5291fb9d..e752810b6545 100644 --- a/src/mail-app/mail/view/MailListView.ts +++ b/src/mail-app/mail/view/MailListView.ts @@ -29,12 +29,12 @@ import { BootIcons } from "../../../common/gui/base/icons/BootIcons.js" import { theme } from "../../../common/gui/theme.js" import { VirtualRow } from "../../../common/gui/base/ListUtils.js" import { isKeyPressed } from "../../../common/misc/KeyManager.js" -import { ListModel } from "../../../common/misc/ListModel.js" import { mailLocator } from "../../mailLocator.js" import { assertSystemFolderOfType } from "../model/MailUtils.js" import { canDoDragAndDropExport } from "./MailViewerUtils.js" import { isOfTypeOrSubfolderOf } from "../model/MailChecks.js" import { DropType } from "../../../common/gui/base/GuiUtils" +import { ListElementListModel } from "../../../common/misc/ListElementListModel" assertMainOrNode() @@ -44,9 +44,9 @@ export interface MailListViewAttrs { onClearFolder: () => unknown mailViewModel: MailViewModel onSingleSelection: (mail: Mail) => unknown - onSingleInclusiveSelection: ListModel["onSingleInclusiveSelection"] - onRangeSelectionTowards: ListModel["selectRangeTowards"] - onSingleExclusiveSelection: ListModel["onSingleExclusiveSelection"] + onSingleInclusiveSelection: ListElementListModel["onSingleInclusiveSelection"] + onRangeSelectionTowards: ListElementListModel["selectRangeTowards"] + onSingleExclusiveSelection: ListElementListModel["onSingleExclusiveSelection"] } export class MailListView implements Component { diff --git a/src/mail-app/mail/view/MailViewModel.ts b/src/mail-app/mail/view/MailViewModel.ts index c6c045916318..f5a0307050e5 100644 --- a/src/mail-app/mail/view/MailViewModel.ts +++ b/src/mail-app/mail/view/MailViewModel.ts @@ -1,4 +1,4 @@ -import { ListModel } from "../../../common/misc/ListModel.js" +import { ListElementListModel } from "../../../common/misc/ListElementListModel.js" import { MailboxDetail, MailboxModel } from "../../../common/mailFunctionality/MailboxModel.js" import { EntityClient } from "../../../common/api/common/EntityClient.js" import { @@ -386,7 +386,7 @@ export class MailViewModel { this.eventController.addEntityListener((updates) => this.entityEventsReceived(updates)) }) - get listModel(): ListModel | null { + get listModel(): ListElementListModel | null { return this._folder ? this.listModelForFolder(getElementId(this._folder)) : null } @@ -426,7 +426,7 @@ export class MailViewModel { // We need to populate mail set entries cache when loading mails so that we can react to updates later. const mailSetEntries = this.mailSetEntries() const folder = assertNotNull(this._folder) - return new ListModel({ + return new ListElementListModel({ fetch: async (lastFetchedMail, count) => { // in case the folder is a new MailSet folder we need to load via the MailSetEntry index indirection let startId: Id diff --git a/src/mail-app/search/view/SearchListView.ts b/src/mail-app/search/view/SearchListView.ts index 7a87ccbdb36e..25c9e9607c3c 100644 --- a/src/mail-app/search/view/SearchListView.ts +++ b/src/mail-app/search/view/SearchListView.ts @@ -2,7 +2,7 @@ import m, { Children, Component, Vnode } from "mithril" import { assertMainOrNode } from "../../../common/api/common/Env" import { downcast, isSameTypeRef, TypeRef } from "@tutao/tutanota-utils" import { MailRow } from "../../mail/view/MailRow" -import { ListModel } from "../../../common/misc/ListModel.js" +import { ListElementListModel } from "../../../common/misc/ListElementListModel.js" import { List, ListAttrs, MultiselectMode, RenderConfig } from "../../../common/gui/base/List.js" import { size } from "../../../common/gui/size.js" import { KindaContactRow } from "../../contacts/view/ContactListView.js" @@ -28,7 +28,7 @@ export class SearchResultListEntry { } export interface SearchListViewAttrs { - listModel: ListModel + listModel: ListElementListModel onSingleSelection: (item: SearchResultListEntry) => unknown currentType: TypeRef | TypeRef | TypeRef isFreeAccount: boolean @@ -39,7 +39,7 @@ export interface SearchListViewAttrs { export class SearchListView implements Component { private attrs: SearchListViewAttrs - private get listModel(): ListModel { + private get listModel(): ListElementListModel { return this.attrs.listModel } diff --git a/src/mail-app/search/view/SearchViewModel.ts b/src/mail-app/search/view/SearchViewModel.ts index c4dcb3d44ce3..7cf936faaa2d 100644 --- a/src/mail-app/search/view/SearchViewModel.ts +++ b/src/mail-app/search/view/SearchViewModel.ts @@ -1,4 +1,4 @@ -import { ListFilter, ListModel } from "../../../common/misc/ListModel.js" +import { ListElementListModel } from "../../../common/misc/ListElementListModel.js" import { SearchResultListEntry } from "./SearchListView.js" import { SearchRestriction, SearchResult } from "../../../common/api/worker/search/SearchTypes.js" import { EntityEventsListener, EventController } from "../../../common/api/main/EventController.js" @@ -78,6 +78,7 @@ import { getMailFilterForType, MailFilterType } from "../../mail/view/MailViewer import { CalendarEventsRepository } from "../../../common/calendar/date/CalendarEventsRepository.js" import { getClientOnlyCalendars } from "../../../calendar-app/calendar/gui/CalendarGuiUtils.js" import { YEAR_IN_MILLIS } from "@tutao/tutanota-utils/dist/DateUtils.js" +import { ListFilter } from "../../../common/misc/ListModel" const SEARCH_PAGE_SIZE = 100 @@ -89,8 +90,8 @@ export enum PaidFunctionResult { } export class SearchViewModel { - private _listModel: ListModel - get listModel(): ListModel { + private _listModel: ListElementListModel + get listModel(): ListElementListModel { return this._listModel } @@ -874,20 +875,20 @@ export class SearchViewModel { } } - private createList(): ListModel { + private createList(): ListElementListModel { // since we recreate the list every time we set a new result object, // we bind the value of result for the lifetime of this list model // at this point // 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 - return new ListModel({ + return new ListElementListModel({ fetch: async (lastFetchedEntity: SearchResultListEntry, count: number) => { const startId = lastFetchedEntity == null ? GENERATED_MAX_ID : getElementId(lastFetchedEntity) const lastResult = this.searchResult if (lastResult !== this.searchResult) { console.warn("got a fetch request for outdated results object, ignoring") - // this._searchResults was reassigned, we'll create a new ListModel soon + // this._searchResults was reassigned, we'll create a new ListElementListModel soon return { items: [], complete: true } } await awaitSearchInitialized(this.search) diff --git a/src/mail-app/settings/KnowledgeBaseListView.ts b/src/mail-app/settings/KnowledgeBaseListView.ts index d43cf0794310..879ee0073329 100644 --- a/src/mail-app/settings/KnowledgeBaseListView.ts +++ b/src/mail-app/settings/KnowledgeBaseListView.ts @@ -15,7 +15,7 @@ import { KnowledgeBaseEntryView } from "../knowledgebase/view/KnowledgeBaseEntry import { memoized, NBSP, noOp } from "@tutao/tutanota-utils" import { assertMainOrNode } from "../../common/api/common/Env" import { SelectableRowContainer, SelectableRowSelectedSetter } from "../../common/gui/SelectableRowContainer.js" -import { ListModel } from "../../common/misc/ListModel.js" +import { ListElementListModel } from "../../common/misc/ListElementListModel.js" import { listSelectionKeyboardShortcuts, onlySingleSelection, VirtualRow } from "../../common/gui/base/ListUtils.js" import Stream from "mithril/stream" import { List, ListAttrs, MultiselectMode, RenderConfig } from "../../common/gui/base/List.js" @@ -40,7 +40,7 @@ export class KnowledgeBaseListView implements UpdatableSettingsViewer { private searchQuery: string = "" private resultItemIds: Array = [] - private listModel: ListModel + private listModel: ListElementListModel private listStateSubscription: Stream | null = null private readonly renderConfig: RenderConfig = { itemHeight: size.list_row_height, @@ -81,7 +81,7 @@ export class KnowledgeBaseListView implements UpdatableSettingsViewer { } private makeListModel() { - const listModel = new ListModel({ + const listModel = new ListElementListModel({ sortCompare: (a: KnowledgeBaseEntry, b: KnowledgeBaseEntry) => { const titleA = a.title.toUpperCase() const titleB = b.title.toUpperCase() diff --git a/src/mail-app/settings/TemplateListView.ts b/src/mail-app/settings/TemplateListView.ts index d49defd77b82..11f56ae827bf 100644 --- a/src/mail-app/settings/TemplateListView.ts +++ b/src/mail-app/settings/TemplateListView.ts @@ -14,7 +14,7 @@ import { ListColumnWrapper } from "../../common/gui/ListColumnWrapper" import { memoized, noOp } from "@tutao/tutanota-utils" import { assertMainOrNode } from "../../common/api/common/Env" import { SelectableRowContainer, SelectableRowSelectedSetter } from "../../common/gui/SelectableRowContainer.js" -import { ListModel } from "../../common/misc/ListModel.js" +import { ListElementListModel } from "../../common/misc/ListElementListModel.js" import Stream from "mithril/stream" import ColumnEmptyMessageBox from "../../common/gui/base/ColumnEmptyMessageBox.js" import { theme } from "../../common/gui/theme.js" @@ -40,7 +40,7 @@ export class TemplateListView implements UpdatableSettingsViewer { private searchQuery: string = "" private resultItemIds: ReadonlyArray = [] - private listModel: ListModel + private listModel: ListElementListModel private listStateSubscription: Stream | null = null private readonly renderConfig: RenderConfig = { itemHeight: size.list_row_height, @@ -80,7 +80,7 @@ export class TemplateListView implements UpdatableSettingsViewer { } private makeListModel() { - const listModel = new ListModel({ + const listModel = new ListElementListModel({ sortCompare: (a: EmailTemplate, b: EmailTemplate) => { const titleA = a.title.toUpperCase() const titleB = b.title.toUpperCase() diff --git a/src/mail-app/settings/groups/GroupListView.ts b/src/mail-app/settings/groups/GroupListView.ts index 11c5b0aa14da..6920b3e16d93 100644 --- a/src/mail-app/settings/groups/GroupListView.ts +++ b/src/mail-app/settings/groups/GroupListView.ts @@ -15,7 +15,7 @@ import { SelectableRowContainer, SelectableRowSelectedSetter, setVisibility } fr import Stream from "mithril/stream" import { List, ListAttrs, MultiselectMode, RenderConfig } from "../../../common/gui/base/List.js" import { size } from "../../../common/gui/size.js" -import { ListModel } from "../../../common/misc/ListModel.js" +import { ListElementListModel } from "../../../common/misc/ListElementListModel.js" import { compareGroupInfos } from "../../../common/api/common/utils/GroupUtils.js" import { NotFoundError } from "../../../common/api/common/error/RestError.js" import { listSelectionKeyboardShortcuts, onlySingleSelection, VirtualRow } from "../../../common/gui/base/ListUtils.js" @@ -35,7 +35,7 @@ const className = "group-list" export class GroupListView implements UpdatableSettingsViewer { private searchQuery: string = "" - private listModel: ListModel + private listModel: ListElementListModel private readonly renderConfig: RenderConfig = { itemHeight: size.list_row_height, multiselectionAllowed: MultiselectMode.Disabled, @@ -153,8 +153,8 @@ export class GroupListView implements UpdatableSettingsViewer { } } - private makeListModel(): ListModel { - const listModel = new ListModel({ + private makeListModel(): ListElementListModel { + const listModel = new ListElementListModel({ sortCompare: compareGroupInfos, fetch: async (_lastFetchedEntity, _count) => { // load all entries at once to apply custom sort order diff --git a/test/tests/Suite.ts b/test/tests/Suite.ts index a8960563957f..624dd050bb40 100644 --- a/test/tests/Suite.ts +++ b/test/tests/Suite.ts @@ -1,6 +1,7 @@ import o from "@tutao/otest" import "./misc/ListModelTest.js" +import "./misc/ListElementListModelTest.js" import "./api/worker/facades/LoginFacadeTest.js" import "./api/common/utils/LoggerTest.js" import "./api/common/utils/BirthdayUtilsTest.js" diff --git a/test/tests/misc/ListElementListModelTest.ts b/test/tests/misc/ListElementListModelTest.ts new file mode 100644 index 000000000000..a7fb9e5cdcbc --- /dev/null +++ b/test/tests/misc/ListElementListModelTest.ts @@ -0,0 +1,224 @@ +import o from "@tutao/otest" +import { getElementId, getListId, sortCompareById } from "../../../src/common/api/common/utils/EntityUtils.js" +import { defer, DeferredObject } from "@tutao/tutanota-utils" +import { KnowledgeBaseEntry, KnowledgeBaseEntryTypeRef } from "../../../src/common/api/entities/tutanota/TypeRefs.js" +import { ListFetchResult } from "../../../src/common/gui/base/ListUtils.js" +import { OperationType } from "../../../src/common/api/common/TutanotaConstants.js" +import { createTestEntity } from "../TestUtils.js" +import { ListAutoSelectBehavior } from "../../../src/common/misc/DeviceConfig.js" +import { ListElementListModel, ListElementListModelConfig } from "../../../src/common/misc/ListElementListModel" + +o.spec("ListElementListModel", function () { + const listId = "listId" + let fetchDefer: DeferredObject> + let listModel: ListElementListModel + const defaultListConfig: ListElementListModelConfig = { + fetch: () => fetchDefer.promise, + sortCompare: sortCompareById, + loadSingle: () => { + throw new Error("noop") + }, + autoSelectBehavior: () => ListAutoSelectBehavior.OLDER, + } + + const itemA = createTestEntity(KnowledgeBaseEntryTypeRef, { + _id: [listId, "a"], + title: "a", + }) + const itemB = createTestEntity(KnowledgeBaseEntryTypeRef, { + _id: [listId, "b"], + title: "b", + }) + const itemC = createTestEntity(KnowledgeBaseEntryTypeRef, { + _id: [listId, "c"], + title: "c", + }) + const itemD = createTestEntity(KnowledgeBaseEntryTypeRef, { + _id: [listId, "d"], + title: "d", + }) + + const items = [itemA, itemB, itemC, itemD] + + async function setItems(items: KnowledgeBaseEntry[]) { + fetchDefer.resolve({ items, complete: true }) + await listModel.loadInitial() + } + + o.beforeEach(function () { + fetchDefer = defer>() + listModel = new ListElementListModel(defaultListConfig) + }) + + function getSortedSelection() { + return listModel.getSelectedAsArray().sort(sortCompareById) + } + + o.spec("selection controls", function () { + o.spec("selectPrevious/selectNext", function () { + o("when the active item is deleted selectPrevious single will still select previous item relative to it", async function () { + await setItems(items) + listModel.onSingleInclusiveSelection(itemB) + await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) + // start state: + // + // A + // (B) active, gone + // C + // D + // + // end state: + // + // A + active + // C + // D + listModel.selectPrevious(false) + o(getSortedSelection()).deepEquals([itemA]) + o(listModel.state.inMultiselect).equals(false) + o(listModel.state.activeIndex).equals(0) + }) + + o("when the active item is deleted selectPrevious multiselect will still select previous item relative to it", async function () { + await setItems(items) + listModel.onSingleInclusiveSelection(itemB) + await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) + // start state: + // + // A + // (B) active, gone + // C + // D + // + // end state: + // + // A + active + // C + // D + listModel.selectPrevious(true) + o(getSortedSelection()).deepEquals([itemA]) + o(listModel.state.inMultiselect).equals(true) + o(listModel.state.activeIndex).equals(0) + }) + + o("when the active item is deleted selectNext single will still select next item relative to it", async function () { + await setItems(items) + listModel.onSingleInclusiveSelection(itemB) + await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) + // start state: + // + // A + // (B) active, gone + // C + // D + // + // end state: + // + // A + // C + active + // D + listModel.selectNext(false) + o(getSortedSelection()).deepEquals([itemC]) + o(listModel.state.inMultiselect).equals(false) + o(listModel.state.activeIndex).equals(1) + }) + + o("when the active item is deleted selectNext multiselect will still select next item relative to it", async function () { + await setItems(items) + listModel.onSingleInclusiveSelection(itemB) + await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) + // start state: + // + // A + // (B) active, gone + // C + // D + // + // end state: + // + // A + // C + active + // D + listModel.selectNext(true) + o(getSortedSelection()).deepEquals([itemC]) + o(listModel.state.inMultiselect).equals(true) + o(listModel.state.activeIndex).equals(1) + }) + }) + }) + + o.spec("Removing element in list ", function () { + o("in single select, the active element is next entity when active element gets deleted", async function () { + await setItems(items) + listModel.onSingleSelection(itemB) + await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) + + o(listModel.state.activeIndex).equals(1) + }) + + o("in single select, the active element is not changed when a different entity is deleted", async function () { + await setItems(items) + listModel.onSingleSelection(itemC) + await listModel.entityEventReceived(getListId(itemA), getElementId(itemA), OperationType.DELETE) + + o(listModel.state.activeIndex).equals(1) + }) + + o("in multiselect, next element is not selected when element is removed", async function () { + await setItems(items) + listModel.onSingleInclusiveSelection(itemB) + await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) + + o(listModel.state.inMultiselect).equals(true) + o(listModel.state.activeIndex).equals(null) + }) + }) + + o.spec("Updating items", function () { + o("update for item with id sorting updates item", async function () { + const updatedItemD = createTestEntity(KnowledgeBaseEntryTypeRef, { ...itemD, title: "AA" }) + + const newConfig: ListElementListModelConfig = { + ...defaultListConfig, + async loadSingle(_listId: Id, elementId: Id): Promise { + if (elementId === getElementId(itemD)) { + return updatedItemD + } else { + throw new Error("noop") + } + }, + } + + listModel = new ListElementListModel(newConfig) + await setItems(items) + + await listModel.entityEventReceived(getListId(itemD), getElementId(itemD), OperationType.UPDATE) + + o(listModel.state.items).deepEquals([itemA, itemB, itemC, updatedItemD]) + }) + + o("update for item with custom sorting changes position", async function () { + const updatedItemD = createTestEntity(KnowledgeBaseEntryTypeRef, { ...itemD, title: "AA" }) + + const newConfig: ListElementListModelConfig = { + ...defaultListConfig, + async loadSingle(_listId: Id, elementId: Id): Promise { + if (elementId === getElementId(itemD)) { + return updatedItemD + } else { + throw new Error("noop") + } + }, + sortCompare: (e1, e2) => { + return e1.title.localeCompare(e2.title) + }, + } + + listModel = new ListElementListModel(newConfig) + await setItems(items) + + await listModel.entityEventReceived(getListId(itemD), getElementId(itemD), OperationType.UPDATE) + + o(listModel.state.items).deepEquals([itemA, updatedItemD, itemB, itemC]) + }) + }) +}) diff --git a/test/tests/misc/ListModelTest.ts b/test/tests/misc/ListModelTest.ts index fdc10623382e..c121194b4ab6 100644 --- a/test/tests/misc/ListModelTest.ts +++ b/test/tests/misc/ListModelTest.ts @@ -1,26 +1,27 @@ import o from "@tutao/otest" import { ListModel, ListModelConfig } from "../../../src/common/misc/ListModel.js" -import { getElementId, getListId, sortCompareById, timestampToGeneratedId } from "../../../src/common/api/common/utils/EntityUtils.js" +import { getElementId, sortCompareById, timestampToGeneratedId } from "../../../src/common/api/common/utils/EntityUtils.js" import { defer, DeferredObject } from "@tutao/tutanota-utils" import { KnowledgeBaseEntry, KnowledgeBaseEntryTypeRef } from "../../../src/common/api/entities/tutanota/TypeRefs.js" import { ListFetchResult } from "../../../src/common/gui/base/ListUtils.js" import { ListLoadingState } from "../../../src/common/gui/base/List.js" import { ConnectionError } from "../../../src/common/api/common/error/RestError.js" -import { OperationType } from "../../../src/common/api/common/TutanotaConstants.js" import { createTestEntity } from "../TestUtils.js" import { ListAutoSelectBehavior } from "../../../src/common/misc/DeviceConfig.js" o.spec("ListModel", function () { const listId = "listId" let fetchDefer: DeferredObject> - let listModel: ListModel - const defaultListConfig = { + let listModel: ListModel + const defaultListConfig: ListModelConfig = { fetch: () => fetchDefer.promise, sortCompare: sortCompareById, loadSingle: () => { throw new Error("noop") }, autoSelectBehavior: () => ListAutoSelectBehavior.OLDER, + getItemId: getElementId, + isSameId: (id1: string, id2: string) => id1 === id2, } const itemA = createTestEntity(KnowledgeBaseEntryTypeRef, { @@ -49,7 +50,7 @@ o.spec("ListModel", function () { o.beforeEach(function () { fetchDefer = defer>() - listModel = new ListModel(defaultListConfig) + listModel = new ListModel(defaultListConfig) }) o.spec("loading states", function () { @@ -409,94 +410,6 @@ o.spec("ListModel", function () { }, ) - o("when the active item is deleted selectPrevious single will still select previous item relative to it", async function () { - await setItems(items) - listModel.onSingleInclusiveSelection(itemB) - await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) - // start state: - // - // A - // (B) active, gone - // C - // D - // - // end state: - // - // A + active - // C - // D - listModel.selectPrevious(false) - o(getSortedSelection()).deepEquals([itemA]) - o(listModel.state.inMultiselect).equals(false) - o(listModel.state.activeIndex).equals(0) - }) - - o("when the active item is deleted selectPrevious multiselect will still select previous item relative to it", async function () { - await setItems(items) - listModel.onSingleInclusiveSelection(itemB) - await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) - // start state: - // - // A - // (B) active, gone - // C - // D - // - // end state: - // - // A + active - // C - // D - listModel.selectPrevious(true) - o(getSortedSelection()).deepEquals([itemA]) - o(listModel.state.inMultiselect).equals(true) - o(listModel.state.activeIndex).equals(0) - }) - - o("when the active item is deleted selectNext single will still select next item relative to it", async function () { - await setItems(items) - listModel.onSingleInclusiveSelection(itemB) - await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) - // start state: - // - // A - // (B) active, gone - // C - // D - // - // end state: - // - // A - // C + active - // D - listModel.selectNext(false) - o(getSortedSelection()).deepEquals([itemC]) - o(listModel.state.inMultiselect).equals(false) - o(listModel.state.activeIndex).equals(1) - }) - - o("when the active item is deleted selectNext multiselect will still select next item relative to it", async function () { - await setItems(items) - listModel.onSingleInclusiveSelection(itemB) - await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) - // start state: - // - // A - // (B) active, gone - // C - // D - // - // end state: - // - // A - // C + active - // D - listModel.selectNext(true) - o(getSortedSelection()).deepEquals([itemC]) - o(listModel.state.inMultiselect).equals(true) - o(listModel.state.activeIndex).equals(1) - }) - o("when the active item is filtered out selectNext multiselect will still select next item relative to it", async function () { await setItems(items) listModel.onSingleInclusiveSelection(itemB) @@ -708,80 +621,4 @@ o.spec("ListModel", function () { }) }) }) - - o.spec("Removing element in list ", function () { - o("in single select, the active element is next entity when active element gets deleted", async function () { - await setItems(items) - listModel.onSingleSelection(itemB) - await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) - - o(listModel.state.activeIndex).equals(1) - }) - - o("in single select, the active element is not changed when a different entity is deleted", async function () { - await setItems(items) - listModel.onSingleSelection(itemC) - await listModel.entityEventReceived(getListId(itemA), getElementId(itemA), OperationType.DELETE) - - o(listModel.state.activeIndex).equals(1) - }) - - o("in multiselect, next element is not selected when element is removed", async function () { - await setItems(items) - listModel.onSingleInclusiveSelection(itemB) - await listModel.entityEventReceived(getListId(itemB), getElementId(itemB), OperationType.DELETE) - - o(listModel.state.inMultiselect).equals(true) - o(listModel.state.activeIndex).equals(null) - }) - }) - - o.spec("Updating items", function () { - o("update for item with id sorting updates item", async function () { - const updatedItemD = createTestEntity(KnowledgeBaseEntryTypeRef, { ...itemD, title: "AA" }) - - const newConfig: ListModelConfig = { - ...defaultListConfig, - async loadSingle(_listId: Id, elementId: Id): Promise { - if (elementId === getElementId(itemD)) { - return updatedItemD - } else { - throw new Error("noop") - } - }, - } - - listModel = new ListModel(newConfig) - await setItems(items) - - await listModel.entityEventReceived(getListId(itemD), getElementId(itemD), OperationType.UPDATE) - - o(listModel.state.items).deepEquals([itemA, itemB, itemC, updatedItemD]) - }) - - o("update for item with custom sorting changes position", async function () { - const updatedItemD = createTestEntity(KnowledgeBaseEntryTypeRef, { ...itemD, title: "AA" }) - - const newConfig: ListModelConfig = { - ...defaultListConfig, - async loadSingle(_listId: Id, elementId: Id): Promise { - if (elementId === getElementId(itemD)) { - return updatedItemD - } else { - throw new Error("noop") - } - }, - sortCompare: (e1, e2) => { - return e1.title.localeCompare(e2.title) - }, - } - - listModel = new ListModel(newConfig) - await setItems(items) - - await listModel.entityEventReceived(getListId(itemD), getElementId(itemD), OperationType.UPDATE) - - o(listModel.state.items).deepEquals([itemA, updatedItemD, itemB, itemC]) - }) - }) })