Skip to content
Draft
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
7 changes: 7 additions & 0 deletions changes/unreleased/54-native-mail-workspace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: feature
issue: 54
pull: 255
platforms: android, desktop
user-facing: yes

Mail now opens as an adaptive native workspace with accounts, mailbox hierarchy, a virtualized inbox, persistent desktop message detail, mobile navigation, and a safe contract-backed Compose entry.
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ internal class DynamicNativeMemoryCache(
}

fun screen(key: DynamicScreenCacheKey, freshOnly: Boolean = false): DynamicScreenSnapshot? {
if (!key.cacheable) return null
val entry = screens.touch(key) ?: return null
if (freshOnly && entry.storedAt.elapsedNow() > freshFor) return null
return entry.snapshot
Expand All @@ -89,6 +90,7 @@ internal class DynamicNativeMemoryCache(
}

fun storeScreen(key: DynamicScreenCacheKey, snapshot: DynamicScreenSnapshot) {
if (!key.cacheable) return
screens.remove(key)
screens[key] = ScreenEntry(snapshot.bounded(), timeSource.markNow())
while (screens.size > maximumScreens) screens.remove(screens.keys.first())
Expand Down Expand Up @@ -127,9 +129,82 @@ internal data class DynamicScreenCacheKey(
val appId: String,
val viewId: String,
val selectedRecordId: String?,
val selectedRecordResourceId: String?,
/** Only non-secret relationship identifiers needed to distinguish a nested collection. */
val selectedRecordScope: Map<String, String>,
val parameterValues: Map<String, String>,
/** Some sparse semantic records do not safely identify their account scope. */
val cacheable: Boolean = true,
)

/**
* Compose loader identity for a selected dynamic record. This deliberately includes the resource
* and relation scope because server IDs such as Inbox may overlap between Mail accounts.
*/
internal data class DynamicScreenSelectionIdentity(
val resourceId: String?,
val recordId: String?,
val recordScope: Map<String, String>,
)

/**
* Immutable identity for a pagination request. A late page may only update the screen, cache, or
* error state while this still matches the active dynamic selection.
*/
internal data class DynamicPaginationRequestIdentity(
val account: String,
val appId: String,
val viewId: String,
val resourceId: String,
val selection: DynamicScreenSelectionIdentity,
val pathParameters: Map<String, String>,
val cacheKey: DynamicScreenCacheKey,
)

internal fun dynamicScreenSelectionIdentity(
resourceId: String?,
recordId: String?,
recordScope: Map<String, String>,
): DynamicScreenSelectionIdentity = DynamicScreenSelectionIdentity(
resourceId = resourceId,
recordId = recordId,
recordScope = recordScope.toSortedMap(),
)

internal fun dynamicPaginationRequestIdentity(
session: NextcloudSession,
appId: String,
viewId: String,
resourceId: String,
selection: DynamicScreenSelectionIdentity,
pathParameters: Map<String, String>,
cacheable: Boolean,
): DynamicPaginationRequestIdentity {
val cacheKey = dynamicScreenCacheKey(
session = session,
appId = appId,
viewId = viewId,
selectedRecordId = selection.recordId,
parameterValues = pathParameters,
selectedRecordResourceId = selection.resourceId,
selectedRecordScope = selection.recordScope,
cacheable = cacheable,
)
return DynamicPaginationRequestIdentity(
account = cacheKey.account,
appId = appId,
viewId = viewId,
resourceId = resourceId,
selection = selection,
pathParameters = pathParameters.toSortedMap(),
cacheKey = cacheKey,
)
}

internal fun DynamicPaginationRequestIdentity.isCurrentDynamicPaginationRequest(
active: DynamicPaginationRequestIdentity?,
): Boolean = this == active

internal data class DynamicScreenSnapshot(
val records: List<NativeRecord>,
val relatedRecords: Map<String, List<NativeRecord>>,
Expand All @@ -147,12 +222,68 @@ internal fun dynamicScreenCacheKey(
viewId: String,
selectedRecordId: String?,
parameterValues: Map<String, String>,
selectedRecordResourceId: String? = null,
selectedRecordScope: Map<String, String> = emptyMap(),
cacheable: Boolean = true,
): DynamicScreenCacheKey = DynamicScreenCacheKey(
account = session.dynamicAccountKey(),
appId = appId,
viewId = viewId,
selectedRecordId = selectedRecordId,
selectedRecordResourceId = selectedRecordResourceId,
selectedRecordScope = selectedRecordScope.toSortedMap(),
parameterValues = parameterValues.toSortedMap(),
cacheable = cacheable,
)

/**
* A screen cache key needs the active parent relation as well as its display record ID. Mailbox
* IDs may overlap between accounts, so omitting accountId can surface a prior mailbox snapshot.
* Keep only relation identifiers; server content, email addresses, and credentials never enter a
* cache key.
*/
internal fun NativeRecord.dynamicScreenCacheScope(): Map<String, String> = buildMap {
put("recordId", id)
fun addScopeFields(fields: Map<String, String?>) {
fields.forEach { (key, value) ->
val semantic = key.lowercase().filter(Char::isLetterOrDigit)
if (semantic in DYNAMIC_SCREEN_SCOPE_RELATIONS) {
value?.takeIf(String::isNotBlank)?.let { scopedValue -> put(semantic, scopedValue) }
}
}
}
addScopeFields(this@dynamicScreenCacheScope.values)
addScopeFields(this@dynamicScreenCacheScope.bindingContext)
}

/**
* A pagination merge must not collapse equal server IDs from different Mail accounts or folders.
* The same non-secret relation scope used for screen caching keeps a generic collection stable.
*/
internal fun NativeRecord.dynamicPaginationRecordIdentity(resourceId: String): String = buildString {
append(resourceId)
append('\u0000')
append(id)
dynamicScreenCacheScope().toSortedMap().forEach { (key, value) ->
append('\u0000')
append(key)
append('=')
append(value)
}
}

private val DYNAMIC_SCREEN_SCOPE_RELATIONS = setOf(
"accountid",
"mailaccountid",
"mailboxid",
"folderid",
"mailfolderid",
"parentid",
"parentmailboxid",
"parentfolderid",
"databaseid",
"messageid",
"threadid",
)

private fun NextcloudSession.dynamicAccountKey(): String =
Expand Down
Loading
Loading