Skip to content

Commit

Permalink
WIP: Encapsulate
Browse files Browse the repository at this point in the history
Co-authored-by: hrb-hub <[email protected]>
  • Loading branch information
paw-hub and hrb-hub committed Jan 3, 2025
1 parent abc680f commit 370a923
Show file tree
Hide file tree
Showing 2 changed files with 252 additions and 102 deletions.
226 changes: 132 additions & 94 deletions src/common/misc/ListElementListModel.ts
Original file line number Diff line number Diff line change
@@ -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> {
Expand All @@ -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)
}
}
Loading

0 comments on commit 370a923

Please sign in to comment.