Skip to content

Commit 515f5bb

Browse files
committed
WIP: Autosave drafts
Closes #4419
1 parent 1d3c1ce commit 515f5bb

File tree

13 files changed

+440
-35
lines changed

13 files changed

+440
-35
lines changed

packages/tutanota-utils/lib/DateUtils.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,42 @@
33
* As functions here do not use Luxon it cannot be used for calculating things in different time zones, they
44
* are dependent on the system time zone.
55
*/
6-
export const DAY_IN_MILLIS = 1000 * 60 * 60 * 24
76

8-
export const YEAR_IN_MILLIS = DAY_IN_MILLIS * 365
7+
/**
8+
* Convert the number of seconds to milliseconds.
9+
* @param seconds seconds to convert
10+
*/
11+
export function secondsToMillis(seconds: number): number {
12+
return seconds * 1000
13+
}
14+
15+
/**
16+
* Convert the number of minutes to milliseconds.
17+
* @param minutes minutes to convert
18+
*/
19+
export function minutesToMillis(minutes: number): number {
20+
return secondsToMillis(minutes * 60)
21+
}
22+
23+
/**
24+
* Convert the number of minutes to milliseconds.
25+
* @param hours minutes to convert
26+
*/
27+
export function hoursToMillis(hours: number): number {
28+
return minutesToMillis(hours * 60)
29+
}
30+
31+
/**
32+
* Convert the number of minutes to milliseconds.
33+
* @param days minutes to convert
34+
*/
35+
export function daysToMillis(days: number): number {
36+
return hoursToMillis(days * 24)
37+
}
38+
39+
export const DAY_IN_MILLIS = daysToMillis(1)
40+
41+
export const YEAR_IN_MILLIS = daysToMillis(365)
942

1043
/**
1144
* dates from before 1970 have negative timestamps and are currently considered edge cases
@@ -135,7 +168,3 @@ export function isValidDate(date: Date): boolean {
135168
export function millisToDays(millis: number): number {
136169
return millis / DAY_IN_MILLIS
137170
}
138-
139-
export function daysToMillis(days: number): number {
140-
return days * DAY_IN_MILLIS
141-
}

packages/tutanota-utils/lib/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export {
8282
isValidDate,
8383
millisToDays,
8484
daysToMillis,
85+
secondsToMillis,
86+
minutesToMillis,
87+
hoursToMillis,
8588
TIMESTAMP_ZERO_YEAR,
8689
} from "./DateUtils.js"
8790
export {

src/calendar-app/calendarLocator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ class CalendarLocator implements CommonLocator {
336336
recipientsModel,
337337
dateProvider,
338338
mailboxProperties,
339+
this.configFacade,
339340
async (mail: Mail) => {
340341
return false
341342
},

src/common/api/main/SyncTracker.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import stream from "mithril/stream"
2-
import { identity } from "@tutao/tutanota-utils"
2+
import { defer, DeferredObject, identity } from "@tutao/tutanota-utils"
33
import Stream from "mithril/stream"
44

55
/**
@@ -9,6 +9,7 @@ import Stream from "mithril/stream"
99
*/
1010
export class SyncTracker {
1111
private readonly _isSyncDone: Stream<boolean>
12+
private readonly syncDone: DeferredObject<unknown> = defer()
1213

1314
constructor() {
1415
this._isSyncDone = stream(false)
@@ -21,5 +22,10 @@ export class SyncTracker {
2122
markSyncAsDone(): void {
2223
console.log("Initial sync done")
2324
this._isSyncDone(true)
25+
this.syncDone.resolve(null)
26+
}
27+
28+
async waitSync(): Promise<void> {
29+
await this.syncDone.promise
2430
}
2531
}

src/common/api/worker/facades/lazy/ConfigurationDatabase.ts

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Aes128Key,
77
Aes256Key,
88
aes256RandomKey,
9+
aesDecrypt,
910
aesEncrypt,
1011
AesKey,
1112
decryptKey,
@@ -14,14 +15,15 @@ import {
1415
unauthenticatedAesDecrypt,
1516
} from "@tutao/tutanota-crypto"
1617
import { UserFacade } from "../UserFacade.js"
17-
import { EncryptedDbKeyBaseMetaData, EncryptedIndexerMetaData, Metadata, ObjectStoreName } from "../../search/IndexTables.js"
18+
import { EncryptedDbKeyBaseMetaData, EncryptedIndexerMetaData, LocalDraftDataOS, Metadata, ObjectStoreName } from "../../search/IndexTables.js"
1819
import { DbError } from "../../../common/error/DbError.js"
1920
import { checkKeyVersionConstraints, KeyLoaderFacade } from "../KeyLoaderFacade.js"
20-
import type { QueuedBatch } from "../../EventQueue.js"
2121
import { _encryptKeyWithVersionedKey, VersionedKey } from "../../crypto/CryptoWrapper.js"
2222
import { EntityUpdateData, isUpdateForTypeRef } from "../../../common/utils/EntityUpdateUtils"
23+
import * as cborg from "cborg"
24+
import { customTypeDecoders, customTypeEncoders } from "../../offline/OfflineStorage"
2325

24-
const VERSION: number = 2
26+
const VERSION: number = 3
2527
const DB_KEY_PREFIX: string = "ConfigStorage"
2628
const ExternalImageListOS: ObjectStoreName = "ExternalAllowListOS"
2729
export const ConfigurationMetaDataOS: ObjectStoreName = "MetaDataOS"
@@ -43,6 +45,32 @@ export async function decryptLegacyItem(encryptedAddress: Uint8Array, key: Aes25
4345
return utf8Uint8ArrayToString(unauthenticatedAesDecrypt(key, concat(iv, encryptedAddress)))
4446
}
4547

48+
const LOCAL_DRAFT_VERSION: number = 1
49+
export type LocalDraftAddress = {
50+
name: string
51+
address: string
52+
}
53+
export type LocalAutosavedDraftData = {
54+
version: number
55+
56+
saveTime: number
57+
mailId: IdTuple | null
58+
mailGroupId: Id
59+
60+
subject: string
61+
body: string
62+
bodyOnServer: string | null
63+
confidential: boolean
64+
65+
senderAddress: string
66+
to: LocalDraftAddress[]
67+
cc: LocalDraftAddress[]
68+
bcc: LocalDraftAddress[]
69+
}
70+
71+
// We only support one draft maximum, destroying any previous draft if one is currently stored.
72+
const LOCAL_DRAFT_KEY = "current"
73+
4674
/**
4775
* A local configuration database that can be used as an alternative to DeviceConfig:
4876
* Ideal for cases where the configuration values should be stored encrypted,
@@ -65,6 +93,78 @@ export class ConfigurationDatabase {
6593
})
6694
}
6795

96+
/**
97+
* Save the draft data to the database, overwriting one if there is one there.
98+
* @param draftUpdateDataWithoutVersion data to write
99+
*/
100+
async setAutosavedDraftData(draftUpdateDataWithoutVersion: Omit<LocalAutosavedDraftData, "version">): Promise<void> {
101+
const { db, metaData } = await this.db.getAsync()
102+
if (!db.indexingSupported) return
103+
104+
const draftUpdateData: LocalAutosavedDraftData = Object.assign({}, draftUpdateDataWithoutVersion, { version: LOCAL_DRAFT_VERSION })
105+
106+
try {
107+
const transaction = await db.createTransaction(false, [LocalDraftDataOS])
108+
const encoded = cborg.encode(draftUpdateData, { typeEncoders: customTypeEncoders })
109+
const encryptedData = aesEncrypt(metaData.key, encoded, metaData.iv)
110+
await transaction.put(LocalDraftDataOS, LOCAL_DRAFT_KEY, encryptedData)
111+
} catch (e) {
112+
if (e instanceof DbError) {
113+
console.error("failed to save draft:", e.message)
114+
return
115+
}
116+
throw e
117+
}
118+
}
119+
120+
/**
121+
* @return the locally stored draft data, if any, or null
122+
*/
123+
async getAutosavedDraftData(): Promise<LocalAutosavedDraftData | null> {
124+
const { db, metaData } = await this.db.getAsync()
125+
if (!db.indexingSupported) {
126+
return null
127+
}
128+
129+
try {
130+
const transaction = await db.createTransaction(false, [LocalDraftDataOS])
131+
const data = await transaction.get<Uint8Array>(LocalDraftDataOS, LOCAL_DRAFT_KEY)
132+
if (data == null) {
133+
return null
134+
}
135+
136+
const decryptedData = aesDecrypt(metaData.key, data)
137+
const decoded = cborg.decode(decryptedData, { tags: customTypeDecoders })
138+
139+
return decoded as LocalAutosavedDraftData
140+
} catch (e) {
141+
if (e instanceof DbError) {
142+
console.error("failed to load draft:", e.message)
143+
return null
144+
}
145+
throw e
146+
}
147+
}
148+
149+
/**
150+
* Deletes any locally saved draft data, if any
151+
*/
152+
async clearAutosavedDraftData(): Promise<void> {
153+
const { db } = await this.db.getAsync()
154+
if (!db.indexingSupported) return
155+
156+
try {
157+
const transaction = await db.createTransaction(false, [LocalDraftDataOS])
158+
await transaction.delete(LocalDraftDataOS, LOCAL_DRAFT_KEY)
159+
} catch (e) {
160+
if (e instanceof DbError) {
161+
console.error("failed to load draft:", e.message)
162+
return
163+
}
164+
throw e
165+
}
166+
}
167+
68168
async addExternalImageRule(address: string, rule: ExternalImageRule): Promise<void> {
69169
const { db, metaData } = await this.db.getAsync()
70170
if (!db.indexingSupported) return
@@ -101,6 +201,7 @@ export class ConfigurationDatabase {
101201
db.createObjectStore(ExternalImageListOS, {
102202
keyPath: "address",
103203
})
204+
db.createObjectStore(LocalDraftDataOS)
104205
}
105206
const metaData =
106207
(await loadEncryptionMetadata(dbFacade, id, keyLoaderFacade, ConfigurationMetaDataOS)) ||
@@ -118,7 +219,12 @@ export class ConfigurationDatabase {
118219
await deleteTransaction.delete(ExternalImageListOS, entry.key)
119220
}
120221
}
222+
223+
if (event.oldVersion < 3) {
224+
db.createObjectStore(LocalDraftDataOS)
225+
}
121226
})
227+
122228
const metaData =
123229
(await loadEncryptionMetadata(db, id, keyLoaderFacade, ConfigurationMetaDataOS)) ||
124230
(await initializeDb(db, id, keyLoaderFacade, ConfigurationMetaDataOS))

src/common/api/worker/search/IndexTables.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const MetaDataOS: ObjectStoreName = "MetaData"
1010
export const GroupDataOS: ObjectStoreName = "GroupMetaData"
1111
export const SearchTermSuggestionsOS: ObjectStoreName = "SearchTermSuggestions"
1212
export const SearchIndexWordsIndex: IndexName = "SearchIndexWords"
13+
export const LocalDraftDataOS: ObjectStoreName = "LocalDraftData"
1314

1415
export const Metadata = Object.freeze({
1516
userEncDbKey: "userEncDbKey",

0 commit comments

Comments
 (0)