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>(