Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation list model #8351

Merged
merged 3 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions packages/tutanota-utils/lib/Utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TypeRef } from "./TypeRef.js"
import { arrayEquals } from "./ArrayUtils.js"

export interface ErrorInfo {
readonly name: string | null
Expand Down Expand Up @@ -217,19 +218,21 @@ export function makeSingleUse<T>(fn: Callback<T>): Callback<T> {
* If the cached argument has changed then {@param fn} will be called with new argument and result will be cached again.
* Only remembers the last argument.
*/
export function memoized<T, R>(fn: (arg0: T) => R): (arg0: T) => R {
let lastArg: T
let lastResult: R
export function memoized<F extends (...args: any[]) => any>(fn: F): F {
let lastArgs: unknown[]
let lastResult: Parameters<F>
let didCache = false
return (arg) => {
if (!didCache || arg !== lastArg) {
lastArg = arg

const memoizedFunction = (...args: Parameters<F>) => {
if (!didCache || !arrayEquals(lastArgs, args)) {
lastArgs = args
didCache = true
lastResult = fn(arg)
lastResult = fn(...args)
}

return lastResult
}
return memoizedFunction as F
}

/**
Expand Down
46 changes: 45 additions & 1 deletion packages/tutanota-utils/test/UtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import o from "@tutao/otest"
import { clone, deepEqual, getChangedProps } from "../lib/Utils.js"
import { clone, deepEqual, getChangedProps, memoized } from "../lib/Utils.js"
import { arrayEquals } from "../lib/index.js"
import { func, matchers, when } from "testdouble"
import { verify } from "@tutao/tutanota-test-utils"

o.spec("utils", function () {
o("deep clone an instance", function () {
Expand Down Expand Up @@ -142,4 +144,46 @@ o.spec("utils", function () {
o(getChangedProps(undefined, undefined)).deepEquals([])
o(getChangedProps(null, null)).deepEquals([])
})

o.spec("memoized", function () {
o.test("when called twice with the same argument it is called once", function () {
const fnToMemoize = func<(_: number) => number>()
when(fnToMemoize(matchers.anything())).thenReturn(42)
const memoizedFn = memoized(fnToMemoize)
o(memoizedFn(1)).equals(42)
o(memoizedFn(1)).equals(42)
verify(fnToMemoize(matchers.anything()), { times: 1 })
})

o.test("when called twice with the different arguments it is called twice", function () {
const fnToMemoize = func<(_: number) => number>()
when(fnToMemoize(1)).thenReturn(11)
when(fnToMemoize(2)).thenReturn(12)
const memoizedFn = memoized(fnToMemoize)
o(memoizedFn(1)).equals(11)
o(memoizedFn(2)).equals(12)
verify(fnToMemoize(1), { times: 1 })
verify(fnToMemoize(2), { times: 1 })
})

o.test("when called twice with the same arguments it is called once", function () {
const fnToMemoize = func<(l: number, r: number) => number>()
when(fnToMemoize(matchers.anything(), matchers.anything())).thenReturn(42)
const memoizedFn = memoized(fnToMemoize)
o(memoizedFn(1, 2)).equals(42)
o(memoizedFn(1, 2)).equals(42)
verify(fnToMemoize(matchers.anything(), matchers.anything()), { times: 1 })
})

o.test("when called twice with different arguments it is called twice", function () {
const fnToMemoize = func<(l: number, r: number) => number>()
when(fnToMemoize(1, 1)).thenReturn(11)
when(fnToMemoize(1, 2)).thenReturn(12)
const memoizedFn = memoized(fnToMemoize)
o(memoizedFn(1, 1)).equals(11)
o(memoizedFn(1, 2)).equals(12)
verify(fnToMemoize(1, 1), { times: 1 })
verify(fnToMemoize(1, 2), { times: 1 })
})
})
})
29 changes: 25 additions & 4 deletions src/common/misc/DeviceConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export enum ListAutoSelectBehavior {
OLDER,
NEWER,
}
export const enum MailListDisplayMode {
CONVERSATIONS = "conversations",
MAILS = "mails",
}

export type LastExternalCalendarSyncEntry = {
lastSuccessfulSync: number | undefined | null
Expand Down Expand Up @@ -53,6 +57,7 @@ interface ConfigObject {
_testAssignments: PersistedAssignmentData | null
offlineTimeRangeDaysByUser: Record<Id, number>
conversationViewShowOnlySelectedMail: boolean
mailListDisplayMode: MailListDisplayMode
/** Stores each users' definition about contact synchronization */
syncContactsWithPhonePreference: Record<Id, boolean>
/** Whether mobile calendar navigation is in the "per week" or "per month" mode */
Expand Down Expand Up @@ -87,13 +92,13 @@ interface ConfigObject {
* Device config for internal user auto login. Only one config per device is stored.
*/
export class DeviceConfig implements UsageTestStorage, NewsItemStorage {
public static Version = 4
public static LocalStorageKey = "tutanotaConfig"
public static readonly Version = 5
public static readonly LocalStorageKey = "tutanotaConfig"

private config!: ConfigObject
private lastSyncStream: Stream<Map<Id, LastExternalCalendarSyncEntry>> = stream(new Map())

constructor(private readonly _version: number, private readonly localStorage: Storage | null) {
constructor(private readonly localStorage: Storage | null) {
this.init()
}

Expand Down Expand Up @@ -134,6 +139,7 @@ export class DeviceConfig implements UsageTestStorage, NewsItemStorage {
_signupToken: signupToken,
offlineTimeRangeDaysByUser: loadedConfig.offlineTimeRangeDaysByUser ?? {},
conversationViewShowOnlySelectedMail: loadedConfig.conversationViewShowOnlySelectedMail ?? false,
mailListDisplayMode: loadedConfig.mailListDisplayMode ?? MailListDisplayMode.CONVERSATIONS,
syncContactsWithPhonePreference: loadedConfig.syncContactsWithPhonePreference ?? {},
isCalendarDaySelectorExpanded: loadedConfig.isCalendarDaySelectorExpanded ?? false,
mailAutoSelectBehavior: loadedConfig.mailAutoSelectBehavior ?? (isApp() ? ListAutoSelectBehavior.NONE : ListAutoSelectBehavior.OLDER),
Expand Down Expand Up @@ -398,6 +404,15 @@ export class DeviceConfig implements UsageTestStorage, NewsItemStorage {
this.writeToStorage()
}

getMailListDisplayMode(): MailListDisplayMode {
return this.config.mailListDisplayMode
}

setMailListDisplayMode(setting: MailListDisplayMode) {
this.config.mailListDisplayMode = setting
this.writeToStorage()
}

getUserSyncContactsWithPhonePreference(id: Id): boolean | null {
return this.config.syncContactsWithPhonePreference[id] ?? null
}
Expand Down Expand Up @@ -510,6 +525,12 @@ export function migrateConfig(loadedConfig: any) {
if (loadedConfig._version < 3) {
migrateConfigV2to3(loadedConfig)
}

// version 4 had no migration

if (loadedConfig._version < 5) {
loadedConfig.mailListDisplayMode = MailListDisplayMode.MAILS
}
}

/**
Expand Down Expand Up @@ -558,4 +579,4 @@ export interface DeviceConfigCredentials {
readonly encryptedPassphraseKey: Base64 | null
}

export const deviceConfig: DeviceConfig = new DeviceConfig(DeviceConfig.Version, client.localStorage() ? localStorage : null)
export const deviceConfig: DeviceConfig = new DeviceConfig(client.localStorage() ? localStorage : null)
18 changes: 10 additions & 8 deletions src/common/misc/ListModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ type PrivateListState<ItemType> = Omit<ListState<ItemType>, "items" | "activeInd
export class ListModel<ItemType, IdType> {
constructor(private readonly config: ListModelConfig<ItemType, IdType>) {}

private loadState: "created" | "initialized" = "created"
private initialLoading: Promise<unknown> | null = null
private loading: Promise<unknown> = Promise.resolve()
private filter: ListFilter<ItemType> | null = null
private rangeSelectionAnchorItem: ItemType | null = null
Expand Down Expand Up @@ -115,7 +115,7 @@ export class ListModel<ItemType, IdType> {
private waitUtilInit(): Promise<unknown> {
const deferred = defer()
const subscription = this.rawStateStream.map(() => {
if (this.loadState === "initialized") {
if (this.initialLoading != null) {
Promise.resolve().then(() => {
subscription.end(true)
deferred.resolve(undefined)
Expand All @@ -126,25 +126,25 @@ export class ListModel<ItemType, IdType> {
}

async loadInitial() {
if (this.loadState !== "created") {
return
// execute the loading only once
if (this.initialLoading == null) {
this.initialLoading = this.doLoad()
}
this.loadState = "initialized"
await this.doLoad()
await this.initialLoading
}

async loadMore() {
if (this.rawState.loadingStatus === ListLoadingState.Loading) {
return this.loading
}
if (this.loadState !== "initialized" || this.rawState.loadingStatus !== ListLoadingState.Idle) {
if (this.initialLoading == null || this.rawState.loadingStatus !== ListLoadingState.Idle) {
return
}
await this.doLoad()
}

async retryLoading() {
if (this.loadState !== "initialized" || this.rawState.loadingStatus !== ListLoadingState.ConnectionLost) {
if (this.initialLoading == null || this.rawState.loadingStatus !== ListLoadingState.ConnectionLost) {
return
}
await this.doLoad()
Expand Down Expand Up @@ -574,6 +574,8 @@ export class ListModel<ItemType, IdType> {

canInsertItem(entity: ItemType): boolean {
if (this.state.loadingStatus === ListLoadingState.Done) {
// If the entire list is loaded, it is always safe to add items, because we can assume we have the entire
// range loaded
return true
}

Expand Down
4 changes: 4 additions & 0 deletions src/common/misc/TranslationKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1889,6 +1889,10 @@ export type TranslationKeyType =
| "yourMessage_label"
| "you_label"
| "emptyString_msg"
| "mailListGrouping_label"
| "mailListGroupingDontGroup_label"
| "mailListGroupingGroupByConversation_label"
| "mailListGroupingHelp_msg"
// Put in temporarily, will be removed soon
| "localAdminGroup_label"
| "assignAdminRightsToLocallyAdministratedUserError_msg"
Expand Down
2 changes: 1 addition & 1 deletion src/common/settings/EditNotificationEmailDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function show(existingTemplate: NotificationMailTemplate | null, customer
m.redraw()
})
// Even though savedHtml is always sanitized changing it might lead to mXSS
const sanitizePreview = memoized<string, string>((html) => {
const sanitizePreview = memoized<(html: string) => string>((html) => {
return htmlSanitizer.sanitizeHTML(html).html
})

Expand Down
Loading