From 0526659348928c59e4918a9c6a701b799b01d988 Mon Sep 17 00:00:00 2001 From: paw <paw-hub@users.noreply.github.com> Date: Fri, 3 Jan 2025 12:27:49 +0100 Subject: [PATCH] WIP: Encapsulate Co-authored-by: hrb-hub <181954414+hrb-hub@users.noreply.github.com> --- src/common/misc/ListElementListModel.ts | 226 ++++++++++++++---------- src/common/misc/ListModel.ts | 128 +++++++++++++- 2 files changed, 252 insertions(+), 102 deletions(-) diff --git a/src/common/misc/ListElementListModel.ts b/src/common/misc/ListElementListModel.ts index aca9080f6c35..477c723a8317 100644 --- a/src/common/misc/ListElementListModel.ts +++ b/src/common/misc/ListElementListModel.ts @@ -1,21 +1,33 @@ -import { ListModel, ListModelConfig } from "./ListModel" -import { elementIdPart, getElementId, isSameId, ListElement } from "../api/common/utils/EntityUtils" +import { ListFilter, ListModel, ListModelConfig } from "./ListModel" +import { getElementId, isSameId, ListElement } from "../api/common/utils/EntityUtils" import { OperationType } from "../api/common/TutanotaConstants" -import { last, lastThrow, remove, settledThen } from "@tutao/tutanota-utils" -import { ListLoadingState } from "../gui/base/List" -import { ListAutoSelectBehavior } from "./DeviceConfig" +import Stream from "mithril/stream" +import { ListLoadingState, ListState } from "../gui/base/List" export type ListElementListModelConfig<ElementType> = Omit<ListModelConfig<ElementType, Id>, "getElementId" | "isSameId"> -// FIXME: Use encapsulation instead of extending ListModel and change all the protected members back to private... -export class ListElementListModel<ElementType extends ListElement> extends ListModel<ElementType, Id> { +export class ListElementListModel<ElementType extends ListElement> { + private readonly listModel: ListModel<ElementType, Id> + private readonly config: ListModelConfig<ElementType, Id> + + get state(): ListState<ElementType> { + return this.listModel.state + } + + get differentItemsSelected(): Stream<ReadonlySet<ElementType>> { + return this.listModel.differentItemsSelected + } + + get stateStream(): Stream<ListState<ElementType>> { + return this.listModel.stateStream + } + constructor(config: ListElementListModelConfig<ElementType>) { - super( - Object.assign({}, config, { - isSameId, - getElementId, - }), - ) + this.config = Object.assign({}, config, { + isSameId, + getElementId, + }) + this.listModel = new ListModel(this.config) } async entityEventReceived(listId: Id, elementId: Id, operation: OperationType): Promise<void> { @@ -27,114 +39,140 @@ export class ListElementListModel<ElementType extends ListElement> extends ListM } // Wait for any pending loading - return settledThen(this.loading, () => { + return this.listModel.waitLoad(() => { 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) + if (this.canCreateEntity(entity)) { + this.listModel.addToLoadedEntities(entity) } } else if (operation === OperationType.UPDATE) { - this.updateLoadedEntity(entity) + this.listModel.updateLoadedEntity(entity) } }) } else if (operation === OperationType.DELETE) { // await this.swipeHandler?.animating - await this.deleteLoadedEntity(elementId) + await this.listModel.deleteLoadedEntity(elementId) } } - private addToLoadedEntities(entity: ElementType) { - const id = getElementId(entity) - if (this.rawState.unfilteredItems.some((item) => getElementId(item) === id)) { - return + private canCreateEntity(entity: ElementType): boolean { + if (this.state.loadingStatus !== ListLoadingState.Done) { + return false } - // 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 }) + // new element is in the loaded range or newer than the first element + const lastElement = this.listModel.getLastElement() + return lastElement != null && this.config.sortCompare(entity, lastElement) < 0 } - 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. + async loadAndSelect( + itemId: Id, + shouldStop: () => boolean, + finder: (a: ElementType) => boolean = (item) => this.config.isSameId(this.config.getElementId(item), itemId), + ): Promise<ElementType | null> { + return this.listModel.loadAndSelect(itemId, shouldStop, finder) + } - // 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 + isItemSelected(itemId: Id): boolean { + return this.listModel.isItemSelected(itemId) + } - // 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) - } + enterMultiselect() { + return this.listModel.enterMultiselect() + } - // 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) - } - } + stopLoading(): void { + return this.listModel.stopLoading() + } - // 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 + isEmptyAndDone(): boolean { + return this.listModel.isEmptyAndDone() + } - if (positionToUpdateUnfiltered !== -1 || positionToUpdateFiltered !== -1 || activeElementUpdated) { - this.updateState({ unfilteredItems, filteredItems, selectedItems, activeElement: newActiveElement }) - } + isSelectionEmpty(): boolean { + return this.listModel.isSelectionEmpty() + } - // keep anchor up-to-date - if (this.rangeSelectionAnchorElement != null && isSameId(this.rangeSelectionAnchorElement._id, entity._id)) { - this.rangeSelectionAnchorElement = entity - } + getUnfilteredAsArray(): Array<ElementType> { + return this.listModel.getUnfilteredAsArray() } - private deleteLoadedEntity(elementId: Id): Promise<void> { - return settledThen(this.loading, () => { - const entity = this.rawState.filteredItems.find((e) => getElementId(e) === elementId) + sort() { + return this.listModel.sort() + } - const selectedItems = new Set(this.rawState.selectedItems) + async loadMore() { + return this.listModel.loadMore() + } - let newActiveElement + async loadAll() { + return this.listModel.loadAll() + } - if (entity) { - const wasEntityRemoved = selectedItems.delete(entity) + async retryLoading() { + return this.listModel.retryLoading() + } - 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) - } - } + onSingleSelection(item: ElementType) { + return this.listModel.onSingleSelection(item) + } - if (newActiveElement) { - selectedItems.add(newActiveElement) - } else { - newActiveElement = this.rawState.activeElement - } - } + onSingleInclusiveSelection(item: ElementType, clearSelectionOnMultiSelectStart?: boolean) { + return this.listModel.onSingleInclusiveSelection(item, clearSelectionOnMultiSelectStart) + } - 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 }) - } - }) + 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<ElementType> | null) { + return this.listModel.setFilter(filter) + } + + getSelectedAsArray(): Array<ElementType> { + 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 84e6a899d7d2..7e863a8c72bd 100644 --- a/src/common/misc/ListModel.ts +++ b/src/common/misc/ListModel.ts @@ -8,10 +8,13 @@ import { first, getFirstOrThrow, last, + lastThrow, memoizedWithHiddenArgument, + remove, setAddAll, setEquals, setMap, + settledThen, } from "@tutao/tutanota-utils" import Stream from "mithril/stream" import stream from "mithril/stream" @@ -59,18 +62,18 @@ 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*/ export class ListModel<ElementType, IdType> { - constructor(protected readonly config: ListModelConfig<ElementType, IdType>) {} + constructor(private readonly config: ListModelConfig<ElementType, IdType>) {} private loadState: "created" | "initialized" = "created" - protected loading: Promise<unknown> = Promise.resolve() + private loading: Promise<unknown> = Promise.resolve() private filter: ListFilter<ElementType> | null = null - protected rangeSelectionAnchorElement: ElementType | null = null + private rangeSelectionAnchorElement: ElementType | null = null get state(): ListState<ElementType> { return this.stateStream() } - protected get rawState(): PrivateListState<ElementType> { + private get rawState(): PrivateListState<ElementType> { return this.rawStateStream() } @@ -107,7 +110,7 @@ export class ListModel<ElementType, IdType> { this.stateStream, ) - protected updateState(newStatePart: Partial<PrivateListState<ElementType>>) { + private updateState(newStatePart: Partial<PrivateListState<ElementType>>) { this.rawStateStream({ ...this.rawState, ...newStatePart }) } @@ -183,7 +186,7 @@ export class ListModel<ElementType, IdType> { return this.loading } - protected applyFilter(newItems: ReadonlyArray<ElementType>): Array<ElementType> { + private applyFilter(newItems: ReadonlyArray<ElementType>): Array<ElementType> { return newItems.filter(this.filter ?? (() => true)) } @@ -346,7 +349,7 @@ export class ListModel<ElementType, IdType> { } } - protected getPreviousItem(oldActiveItem: ElementType | null) { + private getPreviousItem(oldActiveItem: ElementType | null) { return oldActiveItem == null ? first(this.state.items) : findLast(this.state.items, (el) => this.config.sortCompare(el, oldActiveItem) < 0) ?? first(this.state.items) @@ -377,7 +380,7 @@ export class ListModel<ElementType, IdType> { } } - protected getNextItem(oldActiveItem: ElementType | null, lastItem: ElementType | null | undefined) { + private getNextItem(oldActiveItem: ElementType | null, lastItem: ElementType | null | undefined) { return oldActiveItem == null ? first(this.state.items) : lastItem && this.config.sortCompare(lastItem, oldActiveItem) <= 0 @@ -465,6 +468,115 @@ export class ListModel<ElementType, IdType> { this.updateState({ loadingStatus: ListLoadingState.ConnectionLost }) } } + + waitLoad(what: () => any): Promise<any> { + return settledThen(this.loading, what) + } + + addToLoadedEntities(entity: ElementType) { + if (this.rawState.unfilteredItems.some((item) => this.isSameElementById(item, entity))) { + 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 }) + } + + 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) => this.isSameElementById(item, entity)) + 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) => this.isSameElementById(item, entity)) + 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 && this.isSameElementById(this.rawState.activeElement, entity) + 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 && this.isSameElementById(this.rangeSelectionAnchorElement, entity)) { + this.rangeSelectionAnchorElement = entity + } + } + + deleteLoadedEntity(elementId: IdType): Promise<void> { + return settledThen(this.loading, () => { + const entity = this.rawState.filteredItems.find((e) => this.config.isSameId(this.config.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 }) + } + }) + } + + getLastElement(): ElementType | null { + if (this.rawState.unfilteredItems.length > 0) { + return lastThrow(this.rawState.unfilteredItems) + } else { + return null + } + } + + private isSameElementById(element1: ElementType, element2: ElementType): boolean { + const id1 = this.config.getElementId(element1) + const id2 = this.config.getElementId(element2) + return this.config.isSameId(id1, id2) + } } export function selectionAttrsForList<ElementType, IdType>(