diff --git a/changes/unreleased/54-native-mail-workspace.md b/changes/unreleased/54-native-mail-workspace.md new file mode 100644 index 00000000..326bf6f1 --- /dev/null +++ b/changes/unreleased/54-native-mail-workspace.md @@ -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. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt index bdf07dda..4992f0a7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt @@ -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 @@ -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()) @@ -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, val parameterValues: Map, + /** 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, +) + +/** + * 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, + val cacheKey: DynamicScreenCacheKey, +) + +internal fun dynamicScreenSelectionIdentity( + resourceId: String?, + recordId: String?, + recordScope: Map, +): DynamicScreenSelectionIdentity = DynamicScreenSelectionIdentity( + resourceId = resourceId, + recordId = recordId, + recordScope = recordScope.toSortedMap(), +) + +internal fun dynamicPaginationRequestIdentity( + session: NextcloudSession, + appId: String, + viewId: String, + resourceId: String, + selection: DynamicScreenSelectionIdentity, + pathParameters: Map, + 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, val relatedRecords: Map>, @@ -147,12 +222,68 @@ internal fun dynamicScreenCacheKey( viewId: String, selectedRecordId: String?, parameterValues: Map, + selectedRecordResourceId: String? = null, + selectedRecordScope: Map = 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 = buildMap { + put("recordId", id) + fun addScopeFields(fields: Map) { + 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 = diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt index 198a4dc3..f2cc7737 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt @@ -23,18 +23,30 @@ import androidx.compose.ui.unit.dp import dev.obiente.nextcloudnative.app.design.NextcloudPresentation import dev.obiente.nextcloudnative.app.design.NextcloudSpacing import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.DynamicAction +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor +import dev.obiente.nextcloudnative.nativeui.model.DynamicForm +import dev.obiente.nextcloudnative.nativeui.model.DynamicHttpBinding +import dev.obiente.nextcloudnative.nativeui.model.EndpointPolicy import dev.obiente.nextcloudnative.nativeui.model.FieldKind import dev.obiente.nextcloudnative.nativeui.model.FieldSpec import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec import dev.obiente.nextcloudnative.nativeui.model.ViewSpec import dev.obiente.nextcloudnative.nativeui.runtime.GenericNativeAppScreen import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutionResult import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutor +import dev.obiente.nextcloudnative.nativeui.runtime.NativeDatasetContext import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord import dev.obiente.nextcloudnative.nativeui.runtime.NativeScreenState +import dev.obiente.nextcloudnative.nativeui.runtime.preferredNativeMailComposeAction enum class MarketingCapturePurpose(val manifestValue: String) { Showcase("showcase"), @@ -88,6 +100,31 @@ enum class MarketingCaptureScenario( "Dynamic apps", "Data table", "Ready", MarketingCapturePurpose.Showcase, "mobile", "phone-portrait", width = 1_080, height = 1_800, density = 2.625f, ), + MailWorkspaceDesktop( + "mail-workspace-desktop", "mail-workspace-desktop.png", NextcloudPresentation.Desktop, + "Mail", "Adaptive mailbox workspace", "Inbox message selected", MarketingCapturePurpose.Showcase, + "desktop", "wide", issue = 54, width = 1_440, height = 900, density = 1f, + ), + MailWorkspaceMobile( + "mail-workspace-mobile", "mail-workspace-mobile.png", NextcloudPresentation.Adaptive, + "Mail", "Adaptive mailbox workspace", "Inbox message list", MarketingCapturePurpose.Showcase, + "mobile", "phone-portrait", issue = 54, width = 1_080, height = 1_800, density = 2.625f, + ), + MailWorkspaceLoadingMobile( + "mail-workspace-loading-mobile", "mail-workspace-loading-mobile.png", NextcloudPresentation.Adaptive, + "Mail", "Adaptive mailbox workspace", "Loading inbox", MarketingCapturePurpose.StateCoverage, + "mobile", "phone-portrait", issue = 54, width = 1_080, height = 1_800, density = 2.625f, + ), + MailWorkspaceEmptyMobile( + "mail-workspace-empty-mobile", "mail-workspace-empty-mobile.png", NextcloudPresentation.Adaptive, + "Mail", "Adaptive mailbox workspace", "Empty inbox", MarketingCapturePurpose.StateCoverage, + "mobile", "phone-portrait", issue = 54, width = 1_080, height = 1_800, density = 2.625f, + ), + MailWorkspaceErrorDesktop( + "mail-workspace-error-desktop", "mail-workspace-error-desktop.png", NextcloudPresentation.Desktop, + "Mail", "Adaptive mailbox workspace", "Message body error", MarketingCapturePurpose.StateCoverage, + "desktop", "wide", issue = 54, width = 1_440, height = 900, density = 1f, + ), PhotoTimelineRevalidationErrorMobile( "photo-timeline-revalidation-error-mobile", "photo-timeline-revalidation-error-mobile.png", @@ -702,6 +739,73 @@ internal fun MarketingAdaptiveAppScenario(scenario: MarketingCaptureScenario) { } } +@Composable +internal fun MarketingMailWorkspaceScenario(scenario: MarketingCaptureScenario) { + require( + scenario in setOf( + MarketingCaptureScenario.MailWorkspaceDesktop, + MarketingCaptureScenario.MailWorkspaceMobile, + MarketingCaptureScenario.MailWorkspaceLoadingMobile, + MarketingCaptureScenario.MailWorkspaceEmptyMobile, + MarketingCaptureScenario.MailWorkspaceErrorDesktop, + ), + ) { + "${scenario.id} is not a Mail workspace capture." + } + val desktop = scenario.presentation == NextcloudPresentation.Desktop + val composeAction = marketingMailDescriptor.preferredNativeMailComposeAction(marketingMailSchema) + val currentView = if (desktop) marketingMailBodyView else marketingMailMessageView + val currentState = when (scenario) { + MarketingCaptureScenario.MailWorkspaceLoadingMobile -> NativeScreenState.Loading + MarketingCaptureScenario.MailWorkspaceEmptyMobile -> NativeScreenState.Ready(emptyList()) + MarketingCaptureScenario.MailWorkspaceErrorDesktop -> NativeScreenState.Error( + message = "The server did not return the selected message body.", + retry = {}, + retryLabel = "Try again", + ) + else -> NativeScreenState.Ready( + if (desktop) listOf(marketingMailBodyRecord) else marketingMailMessages, + ) + } + Column(modifier = Modifier.fillMaxSize()) { + ScreenHeader( + title = "Mail", + subtitle = "Inbox", + onBack = {}, + trailingContent = { + val compose = composeAction + if (compose != null) { + androidx.compose.material3.Button(onClick = {}) { + Text(compose.label) + } + } + }, + ) + GenericNativeAppScreen( + schema = marketingMailSchema, + view = currentView, + state = currentState, + actionExecutor = NativeActionExecutor { + NativeActionExecutionResult.Failure("This synthetic fixture is read-only.") + }, + selectedRecordId = if (desktop) marketingMailSelectedMessage.id else null, + selectedRecordResourceId = if (desktop) "messages" else null, + showSelectedRecordDetail = desktop, + onSelectRecord = {}, + datasetContext = NativeDatasetContext( + parentResourceId = if (desktop) "messages" else "mailboxes", + parentRecord = if (desktop) marketingMailSelectedMessage else marketingMailInbox, + relatedRecords = mapOf( + "accounts" to listOf(marketingMailAccount), + "mailboxes" to marketingMailboxes, + "messages" to marketingMailMessages, + ), + ), + modifier = Modifier.weight(1f), + ) + } +} + @Composable internal fun MarketingHomeDashboardScenario( scenario: MarketingCaptureScenario, @@ -872,6 +976,218 @@ internal val marketingAdaptiveRecords = listOf( ), ) +private val marketingMailComposeAction = DynamicAction( + id = "compose-message", + label = "Compose", + resourceId = "messages", + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + binding = DynamicHttpBinding(method = HttpMethod.POST, path = "/fixture/messages"), + confidence = Confidence.verified, +) + +private val marketingMailSchema = NativeAppSchema( + schemaVersion = "0.1", + app = AppIdentity("fixture-mail", "Mail", "fixture"), + confidence = Confidence.verified, + resources = listOf( + ResourceSpec( + id = "accounts", + name = "Accounts", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("accountName", "Account", FieldKind.string, required = true, readOnly = true), + FieldSpec("emailAddress", "Email", FieldKind.string, required = true, readOnly = true), + ), + ), + ResourceSpec( + id = "mailboxes", + name = "Mailboxes", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("name", "Mailbox", FieldKind.string, required = true, readOnly = true), + FieldSpec("specialUse", "Role", FieldKind.string, required = false, readOnly = true), + FieldSpec("unreadCount", "Unread", FieldKind.integer, required = false, readOnly = true), + FieldSpec("path", "Path", FieldKind.string, required = false, readOnly = true), + ), + ), + ResourceSpec( + id = "messages", + name = "Messages", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("subject", "Subject", FieldKind.string, required = false, readOnly = true), + FieldSpec("from", "From", FieldKind.string, required = false, readOnly = true), + FieldSpec("preview", "Preview", FieldKind.string, required = false, readOnly = true), + FieldSpec("date", "Date", FieldKind.dateTime, required = false, readOnly = true), + FieldSpec("seen", "Seen", FieldKind.boolean, required = false, readOnly = true), + FieldSpec("flagged", "Flagged", FieldKind.boolean, required = false, readOnly = true), + ), + ), + ResourceSpec( + id = "messageBody", + name = "Message body", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("body", "Body", FieldKind.longText, required = true, readOnly = true), + FieldSpec("hasHtmlBody", "HTML", FieldKind.boolean, required = false, readOnly = true), + ), + ), + ), + views = listOf( + ViewSpec( + id = "messages.mailbox", + title = "Inbox", + resourceId = "messages", + component = NativeComponent.mailbox, + sourceActionId = "fixture.messages.list", + confidence = Confidence.verified, + ), + ViewSpec( + id = "message.body", + title = "Message", + resourceId = "messageBody", + component = NativeComponent.detail, + sourceActionId = "fixture.message.body", + confidence = Confidence.verified, + ), + ), + actions = listOf( + ActionSpec( + id = marketingMailComposeAction.id, + label = marketingMailComposeAction.label, + resourceId = marketingMailComposeAction.resourceId, + binding = ApiBinding( + method = marketingMailComposeAction.binding.method, + path = marketingMailComposeAction.binding.path, + operationId = marketingMailComposeAction.id, + ), + intent = marketingMailComposeAction.intent, + risk = marketingMailComposeAction.risk, + requiresConfirmation = marketingMailComposeAction.requiresConfirmation, + confidence = marketingMailComposeAction.confidence, + ), + ), +) + +private val marketingMailDescriptor = DynamicAppDescriptor( + descriptorVersion = "0.1", + app = AppIdentity("fixture-mail", "Mail", "fixture"), + endpointPolicy = EndpointPolicy(serverOrigin = "https://fixture.invalid"), + resources = emptyList(), + actions = listOf(marketingMailComposeAction), + forms = listOf( + DynamicForm( + id = "compose-message-form", + title = "Compose", + resourceId = "messages", + actionId = marketingMailComposeAction.id, + confidence = Confidence.verified, + ), + ), +) + +private val marketingMailMessageView = requireNotNull( + marketingMailSchema.views.firstOrNull { view -> view.id == "messages.mailbox" }, +) +private val marketingMailBodyView = requireNotNull( + marketingMailSchema.views.firstOrNull { view -> view.id == "message.body" }, +) +private val marketingMailAccount = NativeRecord( + id = "personal", + values = mapOf( + "accountName" to "Obiente", + "emailAddress" to "obiente@example.test", + ), +) +private val marketingMailInbox = NativeRecord( + id = "inbox", + values = mapOf( + "name" to "Inbox", + "specialUse" to "inbox", + "unreadCount" to "2", + "path" to "Personal/Inbox", + "accountId" to marketingMailAccount.id, + ), +) +private val marketingMailboxes = listOf( + marketingMailInbox, + NativeRecord( + id = "drafts", + values = mapOf( + "name" to "Drafts", "specialUse" to "drafts", "path" to "Personal/Drafts", + "accountId" to marketingMailAccount.id, + ), + ), + NativeRecord( + id = "sent", + values = mapOf( + "name" to "Sent", "specialUse" to "sent", "path" to "Personal/Sent", + "accountId" to marketingMailAccount.id, + ), + ), + NativeRecord( + id = "archive", + values = mapOf( + "name" to "Archive", "specialUse" to "archive", "path" to "Personal/Archive", + "accountId" to marketingMailAccount.id, + ), + ), +) +private val marketingMailMessages = listOf( + NativeRecord( + id = "mail-1", + values = mapOf( + "subject" to "Release candidate is ready", + "from" to "Ada ", + "preview" to "The Android and desktop artifacts passed the final checks.", + "date" to "2026-07-29T08:42:00Z", + "seen" to "false", + "flagged" to "true", + "accountId" to marketingMailAccount.id, + "mailboxId" to marketingMailInbox.id, + ), + ), + NativeRecord( + id = "mail-2", + values = mapOf( + "subject" to "Design review notes", + "from" to "Mira ", + "preview" to "I added the adaptive navigation feedback to the shared notes.", + "date" to "2026-07-28T17:30:00Z", + "seen" to "false", + "accountId" to marketingMailAccount.id, + "mailboxId" to marketingMailInbox.id, + ), + ), + NativeRecord( + id = "mail-3", + values = mapOf( + "subject" to "Community call", + "from" to "Nextcloud community ", + "preview" to "Here is the agenda for Thursday's community call.", + "date" to "2026-07-27T11:05:00Z", + "seen" to "true", + "accountId" to marketingMailAccount.id, + "mailboxId" to marketingMailInbox.id, + ), + ), +) +private val marketingMailSelectedMessage = marketingMailMessages.first() +private val marketingMailBodyRecord = NativeRecord( + id = marketingMailSelectedMessage.id, + values = mapOf( + "body" to """ +

Hello Obiente,

+

The release candidate is ready for review.

+

Android and desktop artifacts passed the final checks. The visual audit is attached to the build.

+

Thanks,
Ada

+ """.trimIndent(), + "hasHtmlBody" to "true", + ), +) + internal val marketingDashboardSnapshot = NativeDashboardSnapshot( widgets = listOf( marketingDashboardWidget("activity", "Recent activity", 10), diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 3641dc61..d63f5b3b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -128,6 +128,13 @@ import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutionResult import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutor import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionRequest import dev.obiente.nextcloudnative.nativeui.runtime.NativeDatasetContext +import dev.obiente.nextcloudnative.nativeui.runtime.isNativeMailWorkspaceContext +import dev.obiente.nextcloudnative.nativeui.runtime.isNativeMailContainerRecord +import dev.obiente.nextcloudnative.nativeui.runtime.hasNativeMailWorkspaceSemantics +import dev.obiente.nextcloudnative.nativeui.runtime.nativeMailInboxLandingRecord +import dev.obiente.nextcloudnative.nativeui.runtime.nativeMailSoleAccountLandingRecord +import dev.obiente.nextcloudnative.nativeui.runtime.nativeMailScreenCacheScopeIsSafe +import dev.obiente.nextcloudnative.nativeui.runtime.preferredNativeMailComposeAction import dev.obiente.nextcloudnative.nativeui.runtime.NativeImageLoader import dev.obiente.nextcloudnative.nativeui.runtime.NativeAudioRecordPlayer import dev.obiente.nextcloudnative.nativeui.runtime.nativeAudioTrack @@ -436,6 +443,12 @@ fun NextcloudNativeMarketingCapture( MarketingCaptureScenario.AdaptiveApp, MarketingCaptureScenario.AdaptiveAppMobile, -> MarketingAdaptiveAppScenario(scenario) + MarketingCaptureScenario.MailWorkspaceDesktop, + MarketingCaptureScenario.MailWorkspaceMobile, + MarketingCaptureScenario.MailWorkspaceLoadingMobile, + MarketingCaptureScenario.MailWorkspaceEmptyMobile, + MarketingCaptureScenario.MailWorkspaceErrorDesktop, + -> MarketingMailWorkspaceScenario(scenario) MarketingCaptureScenario.PhotoTimelineRevalidationErrorMobile, MarketingCaptureScenario.PhotoTimelineReturnToNewestErrorMobile, MarketingCaptureScenario.PhotoTimelineRawRetryMobile, @@ -1504,6 +1517,14 @@ private fun DynamicDiscoveredAppScreen( var paginationState by remember(descriptor) { mutableStateOf(null) } var loadingMore by remember(descriptor) { mutableStateOf(false) } var loadMoreError by remember(descriptor) { mutableStateOf(null) } + val hasRestoredMailLocation = restoredNavigation.selectedViewId != null || + restoredNavigation.selectedRecord != null || + restoredNavigation.history.isNotEmpty() + var automaticMailLandingStage by remember(descriptor) { + mutableStateOf( + if (hasRestoredMailLocation || !descriptor.hasNativeMailWorkspaceSemantics()) 2 else 0, + ) + } val dynamicRecoveryScope = rememberCoroutineScope() val dynamicPaginationScope = rememberCoroutineScope() val dynamicAssetCache = remember(session.serverUrl, session.loginName, descriptor.app.id) { @@ -1607,14 +1628,34 @@ private fun DynamicDiscoveredAppScreen( ) } - LaunchedEffect(descriptor, selectedView?.id, selectedRecord?.id, selectedPathParameterValues, loadAttempt) { + val selectedRecordCacheScope = selectedRecord?.dynamicScreenCacheScope().orEmpty() + val selectedScreenIdentity = dynamicScreenSelectionIdentity( + resourceId = selectedRecordResourceId, + recordId = selectedRecord?.id, + recordScope = selectedRecordCacheScope, + ) + val screenCacheAllowed = !descriptor.hasNativeMailWorkspaceSemantics() || + nativeMailScreenCacheScopeIsSafe(schema, selectedRecordResourceId, selectedRecord) + + LaunchedEffect( + descriptor, + selectedView?.id, + selectedScreenIdentity, + selectedPathParameterValues, + screenCacheAllowed, + loadAttempt, + ) { val view = selectedView ?: return@LaunchedEffect + if (!screenCacheAllowed) recordsByResourceId = emptyMap() val cacheKey = dynamicScreenCacheKey( session = session, appId = descriptor.app.id, viewId = view.id, selectedRecordId = selectedRecord?.id, parameterValues = selectedPathParameterValues, + selectedRecordResourceId = selectedRecordResourceId, + selectedRecordScope = selectedRecordCacheScope, + cacheable = screenCacheAllowed, ) paginationState = null loadingMore = false @@ -1954,26 +1995,37 @@ private fun DynamicDiscoveredAppScreen( val secondaryViewIds = secondaryNavigationDestinations.mapTo(hashSetOf()) { (_, view) -> view.id } navigationDestinations.filterNot { (_, view) -> view.id in secondaryViewIds } } - val actionViews = remember(navigationPlan, schema, selectedRecord, selectedView.resourceId) { - val planned = if (selectedRecord == null) { - navigationPlan.rootFormActions.filter { action -> - action.resourceId == selectedView.resourceId - } - } else { - val currentResourceId = selectedRecordResourceId.orEmpty() - navigationPlan.contextualFormActions.filter { action -> - val spec = schema.action(action.actionId) - val targetsCurrentRecord = action.resourceId.sameDynamicResourceAs(currentResourceId) - val targetsCurrentView = action.resourceId.sameDynamicResourceAs(selectedView.resourceId) - val createsCurrentViewResource = spec?.intent == ActionIntent.create && targetsCurrentView - val editsSelectedRecord = spec?.intent in setOf(ActionIntent.update, ActionIntent.delete) && - targetsCurrentRecord && - (targetsCurrentView || selectedView.component == NativeComponent.detail) - // Create actions belong to the active collection tab. Update and - // delete actions belong to the selected record and stay visible - // on its detail surface, not on unrelated child collections. - createsCurrentViewResource || editsSelectedRecord - } + val actionViews = remember(descriptor, navigationPlan, schema, selectedRecord, selectedView.resourceId) { + val planned = buildList { + addAll(if (selectedRecord == null) { + navigationPlan.rootFormActions.filter { action -> + action.resourceId == selectedView.resourceId + } + } else { + val currentResourceId = selectedRecordResourceId.orEmpty() + navigationPlan.contextualFormActions.filter { action -> + val spec = schema.action(action.actionId) + val targetsCurrentRecord = action.resourceId.sameDynamicResourceAs(currentResourceId) + val targetsCurrentView = action.resourceId.sameDynamicResourceAs(selectedView.resourceId) + val editsMailContainer = descriptor.hasNativeMailWorkspaceSemantics() && + selectedRecord?.let { record -> + isNativeMailContainerRecord(schema, currentResourceId, record) + } == true + val createsCurrentViewResource = spec?.intent == ActionIntent.create && targetsCurrentView + val editsSelectedRecord = spec?.intent in setOf(ActionIntent.update, ActionIntent.delete) && + targetsCurrentRecord && + (targetsCurrentView || selectedView.component == NativeComponent.detail || editsMailContainer) + // Create actions belong to the active collection tab. Update and + // delete actions belong to the selected record and stay visible + // on its detail surface, not on unrelated child collections. + createsCurrentViewResource || editsSelectedRecord + } + }) + if (selectedRecord != null) { + descriptor.preferredNativeMailComposeAction(schema)?.let(::add) + } + }.distinctBy { action -> + "${action.formId}:${action.actionId}:${action.pathParameterValues}" } planned.mapNotNull { action -> schema.views.firstOrNull { it.id == action.formId }?.let { view -> action to view } @@ -2015,9 +2067,48 @@ private fun DynamicDiscoveredAppScreen( loadMoreError = null val pagingView = selectedView val existingRecords = (viewState as? NativeScreenState.Ready)?.records.orEmpty() - val values = selectedRecord?.toDynamicRuntimeValues().orEmpty() + - selectedPathParameterValues + + val pagingSelection = selectedScreenIdentity + val pagingPathParameters = selectedPathParameterValues.toMap() + val pagingCacheable = screenCacheAllowed + val pagingRequestIdentity = dynamicPaginationRequestIdentity( + session = session, + appId = descriptor.app.id, + viewId = pagingView.id, + resourceId = pagingView.resourceId, + selection = pagingSelection, + pathParameters = pagingPathParameters, + cacheable = pagingCacheable, + ) + val pagingRuntimeValues = selectedRecord?.toDynamicRuntimeValues().orEmpty().toMap() + val values = pagingRuntimeValues + + pagingPathParameters + (pagination.spec.parameterName to pagination.nextRequestValue) + fun isPagingRequestCurrent(): Boolean { + val activeView = schema.views.firstOrNull { view -> view.id == selectedViewId } + ?: return false + val activeRecord = selectedRecord + val activeSelection = dynamicScreenSelectionIdentity( + resourceId = selectedRecordResourceId, + recordId = activeRecord?.id, + recordScope = activeRecord?.dynamicScreenCacheScope().orEmpty(), + ) + val activeCacheable = !descriptor.hasNativeMailWorkspaceSemantics() || + nativeMailScreenCacheScopeIsSafe( + schema, + selectedRecordResourceId, + activeRecord, + ) + val activeIdentity = dynamicPaginationRequestIdentity( + session = session, + appId = descriptor.app.id, + viewId = activeView.id, + resourceId = activeView.resourceId, + selection = activeSelection, + pathParameters = selectedPathParameterValues, + cacheable = activeCacheable, + ) + return pagingRequestIdentity.isCurrentDynamicPaginationRequest(activeIdentity) + } dynamicPaginationScope.launch { runCatching { loadDynamicRecords( @@ -2029,37 +2120,38 @@ private fun DynamicDiscoveredAppScreen( runtimeContext = values, ) }.onSuccess { pageRecords -> - if (selectedViewId != pagingView.id) return@onSuccess - val existingIds = existingRecords.mapTo(hashSetOf(), NativeRecord::id) - val novelRecords = pageRecords.distinctBy(NativeRecord::id) - .filterNot { record -> record.id in existingIds } + if (!isPagingRequestCurrent()) return@onSuccess + val existingIdentities = existingRecords.mapTo(hashSetOf()) { record -> + record.dynamicPaginationRecordIdentity(pagingView.resourceId) + } + val novelRecords = pageRecords + .distinctBy { record -> record.dynamicPaginationRecordIdentity(pagingView.resourceId) } + .filterNot { record -> + record.dynamicPaginationRecordIdentity(pagingView.resourceId) in existingIdentities + } val mergedRecords = existingRecords + novelRecords - recordsByResourceId = recordsByResourceId + (pagingView.resourceId to mergedRecords) + val updatedRecords = recordsByResourceId + (pagingView.resourceId to mergedRecords) viewState = NativeScreenState.Ready(mergedRecords) - paginationState = pagination.spec.toDynamicPaginationState( + val nextPagination = pagination.spec.toDynamicPaginationState( viewId = pagingView.id, lastPage = pageRecords, loadedRecordCount = mergedRecords.size, novelRecordCount = novelRecords.size, nextPageNumber = pagination.nextPageNumber + 1, ) + recordsByResourceId = updatedRecords + paginationState = nextPagination sharedDynamicNativeMemoryCache.storeScreen( - dynamicScreenCacheKey( - session = session, - appId = descriptor.app.id, - viewId = pagingView.id, - selectedRecordId = selectedRecord?.id, - parameterValues = selectedPathParameterValues, - ), + pagingRequestIdentity.cacheKey, DynamicScreenSnapshot( records = mergedRecords, - relatedRecords = recordsByResourceId, - pagination = paginationState?.toCheckpoint(), + relatedRecords = updatedRecords, + pagination = nextPagination?.toCheckpoint(), ), ) loadingMore = false }.onFailure { failure -> - if (selectedViewId != pagingView.id) return@onFailure + if (!isPagingRequestCurrent()) return@onFailure loadMoreError = failure.message ?: "Could not load the next page." loadingMore = false } @@ -2078,6 +2170,95 @@ private fun DynamicDiscoveredAppScreen( ) } + fun selectDynamicRecord(record: NativeRecord) { + rememberCurrentLocation() + val selectedParentResourceId = record.effectiveNativeResourceId(selectedView.resourceId) + val inheritedParameters = inheritDynamicParentParameters( + selectedPathParameterValues = selectedPathParameterValues, + runtimeValues = runtimeValues, + ) + val nextContext = DynamicResourceRecordContext( + resourceId = selectedParentResourceId, + recordId = record.id, + fieldValues = record.values, + parameterValues = inheritedParameters, + actionSafeIdentity = record.actionSafeIdentity, + currentLayoutId = selectedView.id, + ) + val nextPlan = descriptor.planDynamicNavigation(nextContext) + val compositeTarget = schema.views.firstOrNull { candidate -> + candidate.compositeDataGrid?.parentResourceId == selectedParentResourceId + } + val compositeActionIds = compositeTarget?.compositeDataGrid?.let { grid -> + setOf(grid.columnSourceActionId, grid.rowSourceActionId) + }.orEmpty() + val detailResolution = schema.bestDynamicDetailView(selectedParentResourceId) + ?.takeIf { target -> target.id != selectedView.id } + ?.let { target -> + descriptor.resolveDynamicRecordReadParameters(target.sourceActionId, nextContext) + ?.let { parameters -> target to parameters } + } + val detailTarget = detailResolution?.first + val directChild = descriptor.singleSafeContextualChild( + context = nextContext, + hasDedicatedSurface = compositeTarget != null || detailTarget != null, + ) + val preferredCollectionChild = descriptor.preferredSemanticContextualChild(nextContext) + val primaryContentTarget = primaryDynamicContentDestination( + parentResourceId = selectedParentResourceId, + destinations = nextPlan.contextualChildDestinations, + ) + val nextViewId = compositeTarget?.id + ?: primaryContentTarget?.layoutId + ?: preferredCollectionChild?.layoutId + ?: detailTarget?.id + ?: directChild?.layoutId + ?: selectedViewId + val explicitTargetParameters = primaryContentTarget?.pathParameterValues + ?: preferredCollectionChild?.pathParameterValues + ?: directChild?.pathParameterValues + ?: detailResolution?.second + val fallbackTargetParameters = inheritedParameters + + nextPlan.contextualChildDestinations + .filter { destination -> destination.actionId in compositeActionIds } + .flatMap { destination -> destination.pathParameterValues.entries } + .associate(Map.Entry::toPair) + selectedRecord = record + selectedRecordResourceId = selectedParentResourceId + selectedPathParameterValues = resolveDynamicRecordSelectionParameters( + currentViewId = selectedViewId.orEmpty(), + nextViewId = nextViewId.orEmpty(), + currentParameters = selectedPathParameterValues, + explicitTargetParameters = explicitTargetParameters, + fallbackTargetParameters = fallbackTargetParameters, + ) + selectedViewId = nextViewId + } + + LaunchedEffect( + descriptor, + selectedView.id, + viewState, + automaticMailLandingStage, + ) { + val records = (viewState as? NativeScreenState.Ready)?.records ?: return@LaunchedEffect + if (records.isEmpty()) return@LaunchedEffect + val resource = schema.resource(selectedView.resourceId) ?: return@LaunchedEffect + when (automaticMailLandingStage) { + 0 -> { + val account = nativeMailSoleAccountLandingRecord(resource, records) + automaticMailLandingStage = if (account == null) 2 else 1 + account?.let(::selectDynamicRecord) + } + + 1 -> { + val inbox = nativeMailInboxLandingRecord(resource, records) + automaticMailLandingStage = 2 + inbox?.let(::selectDynamicRecord) + } + } + } + fun navigateWithinDynamicApp() { navigationHistory.lastOrNull()?.let { previous -> navigationHistory = navigationHistory.dropLast(1) @@ -2301,85 +2482,36 @@ private fun DynamicDiscoveredAppScreen( } } } + val rendererDatasetContext = NativeDatasetContext( + parentResourceId = selectedRecordResourceId, + parentRecord = selectedRecord, + relatedRecords = recordsByResourceId, + ) + val mailWorkspaceSupportsSelection = schema.resource(selectedView.resourceId)?.let { resource -> + val records = (viewState as? NativeScreenState.Ready)?.records.orEmpty() + isNativeMailWorkspaceContext( + schema = schema, + resource = resource, + records = records, + context = rendererDatasetContext, + ) + } == true GenericNativeAppScreen( schema = schema, view = selectedView, state = viewState, actionExecutor = executor, selectedRecordId = selectedRecord?.id, + selectedRecordResourceId = selectedRecordResourceId, showSelectedRecordDetail = showFallbackRecordDetail, - datasetContext = NativeDatasetContext( - parentResourceId = selectedRecordResourceId, - parentRecord = selectedRecord, - relatedRecords = recordsByResourceId, - ), - onSelectRecord = selectedView.takeIf { - it.component != NativeComponent.detail && it.component != NativeComponent.form - }?.let { - { record -> - rememberCurrentLocation() - val selectedParentResourceId = record.effectiveNativeResourceId(selectedView.resourceId) - val inheritedParameters = inheritDynamicParentParameters( - selectedPathParameterValues = selectedPathParameterValues, - runtimeValues = runtimeValues, - ) - val nextContext = DynamicResourceRecordContext( - resourceId = selectedParentResourceId, - recordId = record.id, - fieldValues = record.values, - parameterValues = inheritedParameters, - actionSafeIdentity = record.actionSafeIdentity, - currentLayoutId = selectedView.id, - ) - val nextPlan = descriptor.planDynamicNavigation(nextContext) - val compositeTarget = schema.views.firstOrNull { candidate -> - candidate.compositeDataGrid?.parentResourceId == selectedParentResourceId - } - val compositeActionIds = compositeTarget?.compositeDataGrid?.let { grid -> - setOf(grid.columnSourceActionId, grid.rowSourceActionId) - }.orEmpty() - val detailResolution = schema.bestDynamicDetailView(selectedParentResourceId) - ?.takeIf { target -> target.id != selectedView.id } - ?.let { target -> - descriptor.resolveDynamicRecordReadParameters(target.sourceActionId, nextContext) - ?.let { parameters -> target to parameters } - } - val detailTarget = detailResolution?.first - val directChild = descriptor.singleSafeContextualChild( - context = nextContext, - hasDedicatedSurface = compositeTarget != null || detailTarget != null, - ) - val preferredCollectionChild = descriptor.preferredSemanticContextualChild(nextContext) - val primaryContentTarget = primaryDynamicContentDestination( - parentResourceId = selectedParentResourceId, - destinations = nextPlan.contextualChildDestinations, - ) - val nextViewId = compositeTarget?.id - ?: primaryContentTarget?.layoutId - ?: preferredCollectionChild?.layoutId - ?: detailTarget?.id - ?: directChild?.layoutId - ?: selectedViewId - val explicitTargetParameters = primaryContentTarget?.pathParameterValues - ?: preferredCollectionChild?.pathParameterValues - ?: directChild?.pathParameterValues - ?: detailResolution?.second - val fallbackTargetParameters = inheritedParameters + - nextPlan.contextualChildDestinations - .filter { destination -> destination.actionId in compositeActionIds } - .flatMap { destination -> destination.pathParameterValues.entries } - .associate(Map.Entry::toPair) - selectedRecord = record - selectedRecordResourceId = selectedParentResourceId - selectedPathParameterValues = resolveDynamicRecordSelectionParameters( - currentViewId = selectedViewId.orEmpty(), - nextViewId = nextViewId.orEmpty(), - currentParameters = selectedPathParameterValues, - explicitTargetParameters = explicitTargetParameters, - fallbackTargetParameters = fallbackTargetParameters, - ) - selectedViewId = nextViewId - } + datasetContext = rendererDatasetContext, + onSelectRecord = if ( + selectedView.component != NativeComponent.form && + (selectedView.component != NativeComponent.detail || mailWorkspaceSupportsSelection) + ) { + ::selectDynamicRecord + } else { + null }, onActionSucceeded = { if (schema.action(selectedView.sourceActionId)?.intent == ActionIntent.delete) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt index a8f3c7d1..34071a98 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt @@ -126,6 +126,7 @@ fun GenericNativeAppScreen( actionExecutor: NativeActionExecutor, modifier: Modifier = Modifier, selectedRecordId: String? = null, + selectedRecordResourceId: String? = null, showSelectedRecordDetail: Boolean = false, onSelectRecord: ((NativeRecord) -> Unit)? = null, onOpenLink: ((String) -> Unit)? = null, @@ -167,6 +168,72 @@ fun GenericNativeAppScreen( nestedBoard != null -> GenericNativeSurface.Board else -> view.genericSurface(presentedResource, presentedRecords) } + val mailWorkspaceSection = remember(schema, presentedResource, datasetContext) { + presentedResource?.let { currentResource -> + nativeMailWorkspaceSection(schema, currentResource, datasetContext) + } ?: NativeMailWorkspaceSection.Unknown + } + val mailWorkspaceEligible = remember(schema, mailWorkspaceSection) { + schema.hasNativeMailWorkspaceSemantics() && + mailWorkspaceSection != NativeMailWorkspaceSection.Unknown + } + val mailWorkspacePlan = remember( + schema, + presentedResource, + presentedRecords, + datasetContext, + selectedRecordId, + selectedRecordResourceId, + mailWorkspaceEligible, + ) { + presentedResource + ?.takeIf { mailWorkspaceEligible } + ?.let { currentResource -> + nativeMailWorkspacePlan( + schema = schema, + currentResource = currentResource, + currentRecords = presentedRecords, + context = datasetContext, + selectedRecordId = selectedRecordId, + selectedRecordResourceId = selectedRecordResourceId, + ) + } + } + val mailWorkspaceDetailTarget = remember( + schema, + presentedResource, + presentedRecords, + datasetContext, + mailWorkspacePlan?.selectedMessage, + ) { + presentedResource + ?.takeIf { mailWorkspaceEligible } + ?.let { currentResource -> + nativeMailWorkspaceDetailTarget( + schema = schema, + currentResource = currentResource, + currentRecords = presentedRecords, + context = datasetContext, + selectedMessage = mailWorkspacePlan?.selectedMessage, + ) + } + } + val mailWorkspaceContentState = remember(state, mailWorkspaceSection) { + when (state) { + NativeScreenState.Loading -> NativeMailWorkspaceContentState.Loading(mailWorkspaceSection) + is NativeScreenState.Error -> NativeMailWorkspaceContentState.Error( + section = mailWorkspaceSection, + message = state.message, + retry = state.retry, + retryLabel = state.retryLabel, + ) + is NativeScreenState.Ready -> if (state.records.isEmpty()) { + NativeMailWorkspaceContentState.Empty(mailWorkspaceSection) + } else { + NativeMailWorkspaceContentState.Ready + } + } + } Surface( modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, @@ -176,6 +243,18 @@ fun GenericNativeAppScreen( Box(modifier = Modifier.weight(1f).fillMaxWidth()) { when { presentedResource == null -> GenericRendererError("This view references an unknown resource.") + mailWorkspacePlan != null && state is NativeScreenState.Loading -> + NativeMailWorkspace( + plan = mailWorkspacePlan, + onSelectRecord = onSelectRecord, + contentState = mailWorkspaceContentState, + ) + mailWorkspacePlan != null && state is NativeScreenState.Error -> + NativeMailWorkspace( + plan = mailWorkspacePlan, + onSelectRecord = onSelectRecord, + contentState = mailWorkspaceContentState, + ) state is NativeScreenState.Loading -> GenericRendererLoading(view.title) state is NativeScreenState.Error -> GenericRendererError( state.message, @@ -190,14 +269,43 @@ fun GenericNativeAppScreen( presentedRecords.firstOrNull() ?: datasetContext.parentRecord, datasetContext, actionExecutor, - filePicker, - onActionSucceeded, + filePicker, + onActionSucceeded, + ) + state is NativeScreenState.Ready && + state.records.isEmpty() && + mailWorkspacePlan != null -> + NativeMailWorkspace( + plan = mailWorkspacePlan, + onSelectRecord = onSelectRecord, + contentState = mailWorkspaceContentState, ) state is NativeScreenState.Ready && presentedRecords.isEmpty() && view.compositeDataGrid == null && nestedBoard == null -> GenericRendererEmpty(presentedResource.name) + state is NativeScreenState.Ready && mailWorkspacePlan != null -> + NativeMailWorkspace( + plan = mailWorkspacePlan, + onSelectRecord = onSelectRecord, + contentState = mailWorkspaceContentState, + detailContent = mailWorkspaceDetailTarget + ?.let { target -> + { + GenericMailMessageDetail( + schema = schema, + resource = target.resource, + record = target.record, + message = target.presentation, + datasetContext = datasetContext, + actionExecutor = actionExecutor, + onActionSucceeded = onActionSucceeded, + onInlineActionSucceeded = onInlineActionSucceeded, + ) + } + }, + ) state is NativeScreenState.Ready -> when (presentedSurface) { GenericNativeSurface.List -> GenericRecordCollection( resource = presentedResource, @@ -1349,7 +1457,7 @@ private fun GenericMailboxCollection( verticalAlignment = Alignment.CenterVertically, ) { Text( - presentation.sender ?: presentation.title, + nativeMailSenderLabel(presentation.sender) ?: presentation.title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy( fontWeight = if (presentation.unread) FontWeight.Bold else FontWeight.Medium, @@ -1357,7 +1465,7 @@ private fun GenericMailboxCollection( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - presentation.timestamp?.let { timestamp -> + nativeMailTimestampLabel(presentation.timestamp)?.let { timestamp -> Text( timestamp, style = MaterialTheme.typography.labelSmall, @@ -1376,7 +1484,7 @@ private fun GenericMailboxCollection( } } } - if (presentation.sender != null) { + if (nativeMailSenderLabel(presentation.sender) != null) { Text( presentation.title, style = MaterialTheme.typography.bodyMedium.copy( @@ -3146,7 +3254,7 @@ private fun GenericGroupwareDetail( @OptIn(ExperimentalRichTextApi::class) @Composable -private fun GenericMailMessageDetail( +internal fun GenericMailMessageDetail( schema: NativeAppSchema, resource: ResourceSpec, record: NativeRecord, @@ -3157,6 +3265,7 @@ private fun GenericMailMessageDetail( onInlineActionSucceeded: (() -> Unit)?, ) { val structured = remember(resource, record) { nativeStructuredDetail(resource, record) } + val threadMessages = remember(resource, record) { nativeMailThreadPresentations(resource, record) } val attachments = structured.sections.filter { section -> section.fieldId.lowercase().filter(Char::isLetterOrDigit) in setOf("attachments", "inlineattachments") } @@ -3224,7 +3333,10 @@ private fun GenericMailMessageDetail( ) } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(message.sender ?: "Unknown sender", style = MaterialTheme.typography.titleMedium) + Text( + nativeMailSenderLabel(message.sender) ?: "Unknown sender", + style = MaterialTheme.typography.titleMedium, + ) message.recipients?.let { recipients -> Text( "To $recipients", @@ -3235,7 +3347,7 @@ private fun GenericMailMessageDetail( ) } } - message.timestamp?.let { timestamp -> + nativeMailTimestampLabel(message.timestamp)?.let { timestamp -> Text( timestamp, style = MaterialTheme.typography.labelMedium, @@ -3275,24 +3387,35 @@ private fun GenericMailMessageDetail( ) } } - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = NextcloudTheme.colors.appTile), - shape = RoundedCornerShape(NextcloudRadii.Card), - ) { - SelectionContainer { - if (!htmlBody.isNullOrBlank()) { - RichText( - state = richTextState, - modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), - style = MaterialTheme.typography.bodyLarge, - ) - } else { - Text( - plainBody?.takeIf(String::isNotBlank) ?: "This message has no readable body.", - modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), - style = MaterialTheme.typography.bodyLarge, - ) + if (threadMessages.size > 1) { + Text( + "${threadMessages.size} messages", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + threadMessages.forEach { threadMessage -> + GenericMailThreadMessage(threadMessage) + } + } else { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = NextcloudTheme.colors.appTile), + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + SelectionContainer { + if (!htmlBody.isNullOrBlank()) { + RichText( + state = richTextState, + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), + style = MaterialTheme.typography.bodyLarge, + ) + } else { + Text( + plainBody?.takeIf(String::isNotBlank) ?: "This message has no readable body.", + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), + style = MaterialTheme.typography.bodyLarge, + ) + } } } } @@ -3357,6 +3480,64 @@ private fun GenericMailMessageDetail( } } +@OptIn(ExperimentalRichTextApi::class) +@Composable +private fun GenericMailThreadMessage(message: NativeMailMessageDetailPresentation) { + val htmlBody = remember(message.body, message.htmlBody) { + message.body?.takeIf { value -> message.htmlBody || value.contains('<') && value.contains('>') } + ?.let(::sanitizeNativeMailHtml) + } + val richTextState = rememberRichTextState() + LaunchedEffect(htmlBody) { + if (!htmlBody.isNullOrBlank()) richTextState.setHtml(htmlBody) + } + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = NextcloudTheme.colors.appTile), + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + nativeMailSenderLabel(message.sender) ?: "Unknown sender", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + nativeMailTimestampLabel(message.timestamp)?.let { timestamp -> + Text( + timestamp, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + SelectionContainer { + if (!htmlBody.isNullOrBlank()) { + RichText( + state = richTextState, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyLarge, + ) + } else { + Text( + message.body?.takeIf(String::isNotBlank) ?: "This message has no readable body.", + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } +} + private data class NativeMailAttachment(val name: String, val mime: String?, val size: String?) private fun NativeStructuredValue.mailAttachments(): List = when (this) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkspace.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkspace.kt new file mode 100644 index 00000000..a1bec42c --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkspace.kt @@ -0,0 +1,1225 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.obiente.nextcloudnative.app.design.NextcloudIcons +import dev.obiente.nextcloudnative.app.design.NextcloudSpacing +import dev.obiente.nextcloudnative.app.design.NextcloudTheme +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor +import dev.obiente.nextcloudnative.nativeui.model.DynamicNavigationFormAction +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec + +internal data class NativeMailWorkspaceItem( + val resource: ResourceSpec, + val record: NativeRecord, + val presentation: NativeMailboxPresentation, + val hierarchyDepth: Int = 0, +) + +internal data class NativeMailWorkspacePlan( + val accounts: List, + val folders: List, + val messages: List, + val currentItems: List, + val selectedContainer: NativeMailWorkspaceItem?, + val selectedMessage: NativeMailWorkspaceItem?, + private val cachedMessagesForSelectedMailbox: List, +) { + val hasMailData: Boolean + get() = accounts.isNotEmpty() || folders.isNotEmpty() || messages.isNotEmpty() + + val preferredInbox: NativeMailWorkspaceItem? + get() = folders.maxByOrNull { item -> item.inboxScore() } + ?.takeIf { item -> item.inboxScore() > 0 } + + val visibleMessages: List + get() { + val currentMessages = currentItems.filter { item -> + item.presentation.kind == NativeMailboxItemKind.Message + } + return when { + currentMessages.isNotEmpty() -> currentMessages + selectedMessage != null -> messages.visibleMailSiblings(selectedMessage) + selectedContainer?.presentation?.kind == NativeMailboxItemKind.Folder -> + cachedMessagesForSelectedMailbox + else -> emptyList() + } + } +} + +internal enum class NativeMailWorkspaceSection { + Accounts, + Mailboxes, + Messages, + MessageDetail, + Unknown, +} + +internal sealed interface NativeMailWorkspaceContentState { + data object Ready : NativeMailWorkspaceContentState + + data class Loading( + val section: NativeMailWorkspaceSection, + ) : NativeMailWorkspaceContentState + + data class Empty( + val section: NativeMailWorkspaceSection, + ) : NativeMailWorkspaceContentState + + data class Error( + val section: NativeMailWorkspaceSection, + val message: String, + val retry: (() -> Unit)?, + val retryLabel: String, + ) : NativeMailWorkspaceContentState +} + +internal fun nativeMailWorkspacePlan( + schema: NativeAppSchema, + currentResource: ResourceSpec, + currentRecords: List, + context: NativeDatasetContext, + selectedRecordId: String?, + selectedRecordResourceId: String? = null, +): NativeMailWorkspacePlan { + val currentIsMessageFacet = context.parentResourceId + ?.takeUnless { parentResourceId -> parentResourceId == currentResource.id } + ?.let(schema::resource) + ?.let { parentResource -> + val parent = context.parentRecord + parent != null && + nativeMailboxPresentation(parentResource, parent).kind == NativeMailboxItemKind.Message && + currentRecords.any { record -> + nativeMailMessageRenderTarget( + schema = schema, + resource = currentResource, + record = record, + context = context, + ) != null + } + } + ?: false + val datasets = buildList { + add(currentResource to currentRecords) + context.relatedRecords.forEach { (resourceId, records) -> + if (resourceId != currentResource.id) { + schema.resource(resourceId)?.let { resource -> add(resource to records) } + } + } + } + val seen = mutableSetOf() + val rawItems = datasets.flatMap { (resource, records) -> + records.mapNotNull { record -> + if (currentIsMessageFacet && resource.id == currentResource.id) return@mapNotNull null + val presentation = nativeMailboxPresentation(resource, record) + if (presentation.kind == NativeMailboxItemKind.Unknown) return@mapNotNull null + val key = nativeMailWorkspaceRecordKey(resource, record, presentation) + if (!seen.add(key)) return@mapNotNull null + NativeMailWorkspaceItem( + resource = resource, + record = record.withNativeResource(resource.id, currentResource.id), + presentation = presentation, + hierarchyDepth = if (presentation.kind == NativeMailboxItemKind.Folder) { + record.mailboxHierarchyDepth() + } else { + 0 + }, + ) + } + } + val minimumFolderDepth = rawItems + .filter { item -> item.presentation.kind == NativeMailboxItemKind.Folder } + .minOfOrNull { item -> item.hierarchyDepth } + ?: 0 + val items = rawItems.map { item -> + if (item.presentation.kind == NativeMailboxItemKind.Folder) { + item.copy(hierarchyDepth = (item.hierarchyDepth - minimumFolderDepth).coerceAtLeast(0)) + } else { + item + } + } + val currentKeys = currentRecords.mapNotNullTo(hashSetOf()) { record -> + nativeMailboxPresentation(currentResource, record) + .takeIf { presentation -> presentation.kind != NativeMailboxItemKind.Unknown } + ?.let { presentation -> nativeMailWorkspaceRecordKey(currentResource, record, presentation) } + } + val currentItems = items.filter { item -> + item.nativeMailWorkspaceRecordKey() in currentKeys + } + val messages = items + .filter { item -> item.presentation.kind == NativeMailboxItemKind.Message } + .sortedWith( + compareByDescending { item -> item.presentation.unread } + .thenByDescending { item -> item.presentation.timestamp.orEmpty() } + .thenBy { item -> item.presentation.title.lowercase() }, + ) + val selectedMessage = context.parentRecord?.let { parent -> + val parentIsMessage = context.parentResourceId + ?.let(schema::resource) + ?.let { resource -> nativeMailboxPresentation(resource, parent).kind } + ?.let { kind -> kind == NativeMailboxItemKind.Message } + ?: false + if (!parentIsMessage) { + null + } else { + items.singleMailWorkspaceSelection( + resourceId = context.parentResourceId, + record = parent, + kind = NativeMailboxItemKind.Message, + ) + } + } ?: items.singleMailWorkspaceSelection( + resourceId = selectedRecordResourceId, + recordId = selectedRecordId, + kind = NativeMailboxItemKind.Message, + ) + val selectedContainer = context.parentRecord?.let { parent -> + context.parentResourceId?.let { parentResourceId -> + items.singleMailWorkspaceSelection( + resourceId = parentResourceId, + record = parent, + kinds = setOf(NativeMailboxItemKind.Account, NativeMailboxItemKind.Folder), + ) + } + } ?: items.singleMailWorkspaceSelection( + resourceId = selectedRecordResourceId, + recordId = selectedRecordId, + kinds = setOf(NativeMailboxItemKind.Account, NativeMailboxItemKind.Folder), + ) + val cachedMessagesForSelectedMailbox = selectedContainer + ?.takeIf { container -> container.presentation.kind == NativeMailboxItemKind.Folder } + ?.let { mailbox -> messages.filter { message -> message.belongsToMailbox(mailbox) } } + .orEmpty() + return NativeMailWorkspacePlan( + accounts = items + .filter { item -> item.presentation.kind == NativeMailboxItemKind.Account } + .sortedBy { item -> item.presentation.title.lowercase() }, + folders = items + .filter { item -> item.presentation.kind == NativeMailboxItemKind.Folder } + .sortedWith( + compareByDescending { item -> item.inboxScore() } + .thenBy { item -> item.mailboxHierarchySortKey() } + .thenBy { item -> item.hierarchyDepth }, + ), + messages = messages, + currentItems = currentItems, + selectedContainer = selectedContainer, + selectedMessage = selectedMessage, + cachedMessagesForSelectedMailbox = cachedMessagesForSelectedMailbox, + ) +} + +/** + * Resolves a selected message body from the active response before falling back to the selected + * message record. A body endpoint commonly returns a separate record that must be merged with + * the selected envelope to render the desktop detail pane. + */ +internal fun nativeMailWorkspaceDetailTarget( + schema: NativeAppSchema, + currentResource: ResourceSpec, + currentRecords: List, + context: NativeDatasetContext, + selectedMessage: NativeMailWorkspaceItem?, +): NativeMailMessageRenderTarget? { + if (selectedMessage == null) return null + return currentRecords.firstNotNullOfOrNull { record -> + nativeMailMessageRenderTarget( + schema = schema, + resource = currentResource, + record = record, + context = context, + ) + } ?: nativeMailMessageRenderTarget( + schema = schema, + resource = selectedMessage.resource, + record = selectedMessage.record, + context = context, + ) +} + +internal fun isNativeMailWorkspaceContext( + schema: NativeAppSchema, + resource: ResourceSpec, + records: List, + context: NativeDatasetContext, +): Boolean = nativeMailWorkspacePlan(schema, resource, records, context, null).hasMailData + +internal fun nativeMailSoleAccountLandingRecord( + resource: ResourceSpec, + records: List, +): NativeRecord? { + if (records.isEmpty()) return null + val accounts = records.filter { record -> + nativeMailboxPresentation(resource, record).kind == NativeMailboxItemKind.Account + } + return accounts.singleOrNull()?.takeIf { accounts.size == records.size } +} + +internal fun nativeMailInboxLandingRecord( + resource: ResourceSpec, + records: List, +): NativeRecord? { + if (records.isEmpty()) return null + val folders = records.mapNotNull { record -> + val presentation = nativeMailboxPresentation(resource, record) + if (presentation.kind != NativeMailboxItemKind.Folder) return@mapNotNull null + NativeMailWorkspaceItem( + resource = resource, + record = record, + presentation = presentation, + hierarchyDepth = record.mailboxHierarchyDepth(), + ) + } + if (folders.size != records.size) return null + val highestScore = folders.maxOfOrNull { item -> item.inboxScore() } ?: return null + if (highestScore <= 0) return null + return folders + .filter { item -> item.inboxScore() == highestScore } + .map { item -> item.record } + .singleOrNull() +} + +internal fun isNativeMailContainerRecord( + schema: NativeAppSchema, + resourceId: String, + record: NativeRecord, +): Boolean = schema.resource(resourceId) + ?.let { resource -> nativeMailboxPresentation(resource, record).kind } + ?.let { kind -> kind == NativeMailboxItemKind.Account || kind == NativeMailboxItemKind.Folder } + ?: false + +/** + * A sparse Mail selection must name its account before it can reuse a screen snapshot. Mailbox + * IDs are commonly reused (for example, Inbox) and guessing their account would show the wrong + * cached messages after an account switch. + */ +internal fun nativeMailScreenCacheScopeIsSafe( + schema: NativeAppSchema, + resourceId: String?, + record: NativeRecord?, +): Boolean { + if (resourceId == null || record == null) return true + val resource = schema.resource(resourceId) ?: return true + return when (val kind = nativeMailboxPresentation(resource, record).kind) { + NativeMailboxItemKind.Unknown -> true + NativeMailboxItemKind.Account, + NativeMailboxItemKind.Folder -> record.mailWorkspaceAccountIds(kind).isNotEmpty() + NativeMailboxItemKind.Message -> + record.mailWorkspaceAccountIds(kind).isNotEmpty() && + record.mailWorkspaceMailboxIds(kind).isNotEmpty() + } +} + +internal fun DynamicAppDescriptor.hasNativeMailWorkspaceSemantics(): Boolean { + val words = resources.map { resource -> + listOf(resource.id, resource.label).flatMap { value -> value.mailSemanticWords() }.toSet() + } + return words.hasCompleteMailWorkspaceSemantics() +} + +internal fun NativeAppSchema.hasNativeMailWorkspaceSemantics(): Boolean { + val words = resources.map { resource -> + listOf(resource.id, resource.name).flatMap { value -> value.mailSemanticWords() }.toSet() + } + return words.hasCompleteMailWorkspaceSemantics() +} + +private fun List>.hasCompleteMailWorkspaceSemantics(): Boolean { + val hasAccounts = any { resourceWords -> + resourceWords.any { word -> word in setOf("account", "accounts", "mailaccount", "mailaccounts") } + } + val hasFolders = any { resourceWords -> + resourceWords.any { word -> + word in setOf("mailbox", "mailboxes", "folder", "folders", "mailfolder", "mailfolders") + } + } + val hasMessages = any { resourceWords -> + resourceWords.any { word -> + word in setOf("message", "messages", "email", "emails", "thread", "threads") + } + } + return hasAccounts && hasFolders && hasMessages +} + +internal fun nativeMailWorkspaceSection( + schema: NativeAppSchema, + resource: ResourceSpec, + context: NativeDatasetContext, +): NativeMailWorkspaceSection { + val parentIsMessage = context.parentRecord?.let { parentRecord -> + context.parentResourceId + ?.let(schema::resource) + ?.let { parentResource -> + nativeMailboxPresentation(parentResource, parentRecord).kind == NativeMailboxItemKind.Message + } + } == true + if (parentIsMessage) return NativeMailWorkspaceSection.MessageDetail + val words = listOf(resource.id, resource.name) + .flatMap { value -> value.mailSemanticWords() } + .toSet() + return when { + words.any { word -> word in setOf("account", "accounts", "mailaccount", "mailaccounts") } -> + NativeMailWorkspaceSection.Accounts + words.any { word -> + word in setOf("mailbox", "mailboxes", "folder", "folders", "mailfolder", "mailfolders") + } -> NativeMailWorkspaceSection.Mailboxes + words.any { word -> + word in setOf("message", "messages", "email", "emails", "thread", "threads") + } -> NativeMailWorkspaceSection.Messages + else -> NativeMailWorkspaceSection.Unknown + } +} + +/** + * Finds a parameter-free compose form using only contract semantics. + * + * The result remains null when the contract exposes several equally plausible routes. This keeps + * the workspace app-neutral and prevents a guessed write from becoming a prominent action. + */ +internal fun DynamicAppDescriptor.preferredNativeMailComposeAction( + schema: NativeAppSchema, +): DynamicNavigationFormAction? { + val actionsById = actions.associateBy { action -> action.id } + val candidates = forms.mapNotNull { form -> + val action = actionsById[form.actionId] ?: return@mapNotNull null + val schemaAction = schema.action(action.id) ?: return@mapNotNull null + if ( + action.binding.method == HttpMethod.GET || + action.binding.pathParameters.isNotEmpty() || + schemaAction.intent != ActionIntent.create + ) { + return@mapNotNull null + } + val resource = schema.resource(form.resourceId) + val resourceWords = listOf(form.resourceId, resource?.name.orEmpty()) + .flatMap { value -> value.mailSemanticWords() } + val actionWords = listOf(form.title, action.label, action.id) + .flatMap { value -> value.mailSemanticWords() } + val mailResource = resourceWords.any { word -> + word in setOf("mail", "message", "messages", "email", "emails", "draft", "drafts", "outbox") + } + val composeIntent = actionWords.any { word -> + word in setOf("compose", "send", "write", "newmessage", "createmessage", "sendmessage") + } + if (!mailResource || !composeIntent) return@mapNotNull null + val score = (if ("compose" in actionWords) 40 else 0) + + (if ("send" in actionWords || "sendmessage" in actionWords) 30 else 0) + + (if (resourceWords.any { it in setOf("message", "messages") }) 20 else 0) + score to DynamicNavigationFormAction( + formId = form.id, + label = form.title, + resourceId = form.resourceId, + actionId = action.id, + ) + } + val highest = candidates.maxOfOrNull { (score, _) -> score } ?: return null + return candidates + .filter { (score, _) -> score == highest } + .map { (_, action) -> action } + .singleOrNull() +} + +@Composable +internal fun NativeMailWorkspace( + plan: NativeMailWorkspacePlan, + onSelectRecord: ((NativeRecord) -> Unit)?, + modifier: Modifier = Modifier, + detailContent: (@Composable () -> Unit)? = null, + contentState: NativeMailWorkspaceContentState = NativeMailWorkspaceContentState.Ready, +) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + when { + maxWidth >= 980.dp -> Row(modifier = Modifier.fillMaxSize()) { + NativeMailRail( + plan = plan, + onSelectRecord = onSelectRecord, + modifier = Modifier.width(252.dp).fillMaxHeight(), + ) + MailPaneDivider() + NativeMailMessageList( + items = plan.visibleMessages, + selectedMessage = plan.selectedMessage, + onSelectRecord = onSelectRecord, + contentState = contentState, + emptyContent = { + NativeMailSelectionPlaceholder(plan) + }, + modifier = Modifier.weight(0.44f).fillMaxHeight(), + ) + MailPaneDivider() + Box(modifier = Modifier.weight(0.56f).fillMaxHeight()) { + if (detailContent != null) { + detailContent() + } else if ( + plan.selectedMessage != null && + contentState != NativeMailWorkspaceContentState.Ready + ) { + NativeMailWorkspaceStatus(contentState) + } else { + NativeMailDetailPlaceholder() + } + } + } + + maxWidth >= 680.dp -> Row(modifier = Modifier.fillMaxSize()) { + NativeMailRail( + plan = plan, + onSelectRecord = onSelectRecord, + modifier = Modifier.width(232.dp).fillMaxHeight(), + ) + MailPaneDivider() + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + if (detailContent != null) { + detailContent() + } else if ( + plan.selectedMessage != null && + contentState != NativeMailWorkspaceContentState.Ready + ) { + NativeMailWorkspaceStatus(contentState) + } else { + NativeMailMessageList( + items = plan.visibleMessages, + selectedMessage = plan.selectedMessage, + onSelectRecord = onSelectRecord, + contentState = contentState, + emptyContent = { + NativeMailSelectionPlaceholder(plan) + }, + ) + } + } + } + + detailContent != null -> detailContent() + + contentState != NativeMailWorkspaceContentState.Ready -> + NativeMailWorkspaceStatus(contentState) + + else -> { + val compactItems = when { + plan.currentItems.isNotEmpty() -> plan.currentItems + plan.visibleMessages.isNotEmpty() -> plan.visibleMessages + plan.folders.isNotEmpty() -> plan.folders + else -> plan.accounts + } + NativeMailMessageList( + items = compactItems, + selectedMessage = plan.selectedMessage, + onSelectRecord = onSelectRecord, + contentState = contentState, + ) + } + } + } +} + +@Composable +private fun NativeMailRail( + plan: NativeMailWorkspacePlan, + onSelectRecord: ((NativeRecord) -> Unit)?, + modifier: Modifier = Modifier, +) { + val selectedKey = plan.selectedContainer?.nativeMailWorkspaceRecordKey() + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + if (plan.accounts.isNotEmpty()) { + item { + Text( + "Accounts", + modifier = Modifier.padding( + start = NextcloudSpacing.Small, + top = NextcloudSpacing.Small, + bottom = NextcloudSpacing.XSmall, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + items(plan.accounts, key = { item -> "account:${item.nativeMailWorkspaceRecordKey()}" }) { item -> + NativeMailRailRow( + item = item, + selected = selectedKey == item.nativeMailWorkspaceRecordKey(), + onSelectRecord = onSelectRecord, + ) + } + } + if (plan.folders.isNotEmpty()) { + item { + Text( + "Mailboxes", + modifier = Modifier.padding( + start = NextcloudSpacing.Small, + top = NextcloudSpacing.Large, + bottom = NextcloudSpacing.XSmall, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + items(plan.folders, key = { item -> "folder:${item.nativeMailWorkspaceRecordKey()}" }) { item -> + NativeMailRailRow( + item = item, + selected = selectedKey == item.nativeMailWorkspaceRecordKey(), + onSelectRecord = onSelectRecord, + ) + } + } + } +} + +@Composable +private fun NativeMailRailRow( + item: NativeMailWorkspaceItem, + selected: Boolean, + onSelectRecord: ((NativeRecord) -> Unit)?, +) { + val interaction = onSelectRecord + ?.let { callback -> Modifier.clickable { callback(item.record) } } + ?: Modifier + Surface( + modifier = interaction + .fillMaxWidth() + .semantics(mergeDescendants = true) { + this.selected = selected + val unreadCount = item.presentation.unreadCount?.takeIf { count -> count > 0 } + if (unreadCount != null) { + stateDescription = "$unreadCount unread" + } else if (selected) { + stateDescription = "Selected" + } + }, + color = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = (item.hierarchyDepth * 12).dp + NextcloudSpacing.Small, + top = NextcloudSpacing.Small, + end = NextcloudSpacing.Small, + bottom = NextcloudSpacing.Small, + ), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (item.presentation.kind == NativeMailboxItemKind.Account) { + NextcloudIcons.app("mail") + } else { + NextcloudIcons.Folder + }, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = NextcloudTheme.colors.appIcon, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + item.presentation.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (item.presentation.unread) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + item.presentation.sender?.let { subtitle -> + Text( + subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + item.presentation.unreadCount?.takeIf { count -> count > 0 }?.let { count -> + Text( + count.toString(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +@Composable +private fun NativeMailMessageList( + items: List, + selectedMessage: NativeMailWorkspaceItem?, + onSelectRecord: ((NativeRecord) -> Unit)?, + modifier: Modifier = Modifier, + contentState: NativeMailWorkspaceContentState = NativeMailWorkspaceContentState.Ready, + emptyContent: (@Composable () -> Unit)? = null, +) { + if (items.isEmpty()) { + if (contentState == NativeMailWorkspaceContentState.Ready && emptyContent != null) { + Box(modifier = modifier.fillMaxSize()) { + emptyContent() + } + } else { + NativeMailWorkspaceStatus( + state = contentState.takeUnless { state -> state == NativeMailWorkspaceContentState.Ready } + ?: NativeMailWorkspaceContentState.Empty(NativeMailWorkspaceSection.Messages), + modifier = modifier, + ) + } + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = NextcloudSpacing.Medium, + top = NextcloudSpacing.Small, + end = NextcloudSpacing.Medium, + bottom = NextcloudSpacing.XXLarge, + ), + ) { + items(items, key = { item -> item.nativeMailWorkspaceRecordKey() }) { item -> + val selected = item.presentation.kind == NativeMailboxItemKind.Message && + item.nativeMailWorkspaceRecordKey() == selectedMessage?.nativeMailWorkspaceRecordKey() + val senderLabel = nativeMailSenderLabel(item.presentation.sender) + val timestampLabel = nativeMailTimestampLabel(item.presentation.timestamp) + val interaction = onSelectRecord + ?.let { callback -> Modifier.clickable { callback(item.record) } } + ?: Modifier + Surface( + modifier = interaction + .fillMaxWidth() + .semantics(mergeDescendants = true) { + this.selected = selected + stateDescription = if (item.presentation.unread) "Unread" else "Read" + }, + color = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = when (item.presentation.kind) { + NativeMailboxItemKind.Folder -> NextcloudIcons.Folder + else -> NextcloudIcons.app("mail") + }, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = NextcloudTheme.colors.appIcon, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Text( + senderLabel ?: item.presentation.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (item.presentation.unread) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + timestampLabel?.let { timestamp -> + Text( + timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + if (senderLabel != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + item.presentation.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (item.presentation.unread) { + FontWeight.SemiBold + } else { + FontWeight.Normal + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + item.presentation.threadSize?.let { count -> + Text( + "$count messages", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + item.presentation.preview?.let { preview -> + Text( + preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (item.presentation.flagged) { + Icon( + NextcloudIcons.Favorite, + contentDescription = "Flagged", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } +} + +@Composable +private fun NativeMailSelectionPlaceholder(plan: NativeMailWorkspacePlan) { + val title: String + val message: String + when { + plan.accounts.isNotEmpty() && plan.selectedContainer == null -> { + title = "Choose a mail account" + message = "Select an account to view its mailboxes." + } + + plan.selectedContainer?.presentation?.kind == NativeMailboxItemKind.Account -> { + title = "Choose a mailbox" + message = "Select a mailbox to view its messages." + } + + else -> { + title = "No messages in this mailbox" + message = "New messages delivered here will appear in this list." + } + } + Box( + modifier = Modifier.fillMaxSize().padding(NextcloudSpacing.XLarge), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + Icon( + NextcloudIcons.app("mail"), + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = NextcloudTheme.colors.appIcon, + ) + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun NativeMailWorkspaceStatus( + state: NativeMailWorkspaceContentState, + modifier: Modifier = Modifier, +) { + val title: String + val message: String + val loading: Boolean + val retry: (() -> Unit)? + val retryLabel: String + when (state) { + NativeMailWorkspaceContentState.Ready -> return + is NativeMailWorkspaceContentState.Loading -> { + loading = true + retry = null + retryLabel = "Try again" + title = when (state.section) { + NativeMailWorkspaceSection.Accounts -> "Loading mail accounts" + NativeMailWorkspaceSection.Mailboxes -> "Loading mailboxes" + NativeMailWorkspaceSection.Messages -> "Loading messages" + NativeMailWorkspaceSection.MessageDetail -> "Opening message" + NativeMailWorkspaceSection.Unknown -> "Loading mail" + } + message = when (state.section) { + NativeMailWorkspaceSection.MessageDetail -> "Fetching the message body and attachments..." + else -> "Fetching the latest mail from your server..." + } + } + is NativeMailWorkspaceContentState.Empty -> { + loading = false + retry = null + retryLabel = "Try again" + title = when (state.section) { + NativeMailWorkspaceSection.Accounts -> "No mail accounts found" + NativeMailWorkspaceSection.Mailboxes -> "No mailboxes found" + NativeMailWorkspaceSection.Messages -> "No messages in this mailbox" + NativeMailWorkspaceSection.MessageDetail -> "No readable message body" + NativeMailWorkspaceSection.Unknown -> "Nothing to show yet" + } + message = when (state.section) { + NativeMailWorkspaceSection.Accounts -> + "Connect a mail account on the server, then refresh this view." + NativeMailWorkspaceSection.Mailboxes -> + "This account did not return any mailboxes." + NativeMailWorkspaceSection.Messages -> + "New messages delivered here will appear in this list." + NativeMailWorkspaceSection.MessageDetail -> + "The envelope is available, but the server returned no readable content." + NativeMailWorkspaceSection.Unknown -> + "Mail content will appear here when the server returns it." + } + } + is NativeMailWorkspaceContentState.Error -> { + loading = false + retry = state.retry + retryLabel = state.retryLabel + title = when (state.section) { + NativeMailWorkspaceSection.Accounts -> "Could not load mail accounts" + NativeMailWorkspaceSection.Mailboxes -> "Could not load mailboxes" + NativeMailWorkspaceSection.Messages -> "Could not refresh this mailbox" + NativeMailWorkspaceSection.MessageDetail -> "Could not open this message" + NativeMailWorkspaceSection.Unknown -> "Could not load mail" + } + message = state.message + } + } + Box(modifier = modifier.fillMaxSize().padding(NextcloudSpacing.XLarge), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + if (loading) { + CircularProgressIndicator(modifier = Modifier.size(32.dp), strokeWidth = 3.dp) + } else { + Icon( + imageVector = if (state is NativeMailWorkspaceContentState.Error) { + NextcloudIcons.Error + } else { + NextcloudIcons.app("mail") + }, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = if (state is NativeMailWorkspaceContentState.Error) { + MaterialTheme.colorScheme.error + } else { + NextcloudTheme.colors.appIcon + }, + ) + } + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + retry?.let { action -> + Button(onClick = action) { + Text(retryLabel) + } + } + } + } +} + +@Composable +private fun NativeMailDetailPlaceholder() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + Icon( + NextcloudIcons.app("mail"), + contentDescription = null, + modifier = Modifier.size(42.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Select a message to read it", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun MailPaneDivider() { + VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) +} + +private fun NativeRecord.withNativeResource( + resourceId: String, + currentResourceId: String, +): NativeRecord = if (resourceId == currentResourceId) { + this +} else { + copy(values = values + (NATIVE_SYNTHETIC_RESOURCE_FIELD to resourceId)) +} + +/** + * Cached mail rows may share a server ID across accounts or collection resources. Keep their + * renderer-local identity composite so an unrelated cache row cannot replace, select, or render + * beside the active mailbox response. + */ +internal fun nativeMailWorkspaceRecordKey( + resource: ResourceSpec, + record: NativeRecord, + presentation: NativeMailboxPresentation, +): String = listOf( + resource.id, + record.id, + presentation.kind.name, + record.mailWorkspaceAccountIds(presentation.kind).sorted().joinToString(","), + record.mailWorkspaceMailboxIds(presentation.kind).sorted().joinToString(","), +).joinToString("\u0000") + +internal fun NativeMailWorkspaceItem.nativeMailWorkspaceRecordKey(): String = + nativeMailWorkspaceRecordKey(resource, record, presentation) + +private fun List.singleMailWorkspaceSelection( + resourceId: String?, + record: NativeRecord, + kind: NativeMailboxItemKind, +): NativeMailWorkspaceItem? = singleMailWorkspaceSelection( + resourceId = resourceId, + recordId = record.id, + recordAccountIds = record.mailWorkspaceAccountIds(kind), + kind = kind, +) + +private fun List.singleMailWorkspaceSelection( + resourceId: String?, + record: NativeRecord, + kinds: Set, +): NativeMailWorkspaceItem? { + val candidates = filter { item -> + item.resource.id == resourceId && + item.record.id == record.id && + item.presentation.kind in kinds + } + val matchingKinds = candidates.map { item -> item.presentation.kind }.toSet() + if (matchingKinds.size != 1) return null + val kind = matchingKinds.single() + return candidates.singleMailWorkspaceSelection( + resourceId = resourceId, + recordId = record.id, + recordAccountIds = record.mailWorkspaceAccountIds(kind), + kind = kind, + ) +} + +private fun List.singleMailWorkspaceSelection( + resourceId: String?, + recordId: String?, + kind: NativeMailboxItemKind, +): NativeMailWorkspaceItem? = singleMailWorkspaceSelection( + resourceId = resourceId, + recordId = recordId, + recordAccountIds = emptySet(), + kind = kind, +) + +private fun List.singleMailWorkspaceSelection( + resourceId: String?, + recordId: String?, + kinds: Set, +): NativeMailWorkspaceItem? { + if (resourceId == null || recordId == null) return null + val candidates = filter { item -> + item.resource.id == resourceId && + item.record.id == recordId && + item.presentation.kind in kinds + } + return candidates.singleOrNull() +} + +private fun List.singleMailWorkspaceSelection( + resourceId: String?, + recordId: String?, + recordAccountIds: Set, + kind: NativeMailboxItemKind, +): NativeMailWorkspaceItem? { + if (resourceId == null || recordId == null) return null + val candidates = filter { item -> + item.resource.id == resourceId && + item.record.id == recordId && + item.presentation.kind == kind + } + if (candidates.size <= 1) return candidates.singleOrNull() + if (recordAccountIds.isEmpty()) return null + return candidates.filter { item -> + item.record.mailWorkspaceAccountIds(item.presentation.kind).intersect(recordAccountIds).isNotEmpty() + }.singleOrNull() +} + +/** + * A cached message is eligible only when it names the active mailbox and, whenever that mailbox + * belongs to an account, also names the same account. Missing relationship values stay hidden: + * guessing would leak an orphaned row into the wrong mailbox when IDs overlap. + */ +private fun NativeMailWorkspaceItem.belongsToMailbox(mailbox: NativeMailWorkspaceItem): Boolean { + val mailboxIds = mailbox.record.mailWorkspaceMailboxIds(mailbox.presentation.kind) + if (mailboxIds.isEmpty()) return false + val messageMailboxIds = record.mailWorkspaceMailboxIds(presentation.kind) + if (messageMailboxIds.intersect(mailboxIds).isEmpty()) return false + val mailboxAccountIds = mailbox.record.mailWorkspaceAccountIds(mailbox.presentation.kind) + if (mailboxAccountIds.isEmpty()) return true + val messageAccountIds = record.mailWorkspaceAccountIds(presentation.kind) + return messageAccountIds.isNotEmpty() && messageAccountIds.intersect(mailboxAccountIds).isNotEmpty() +} + +/** + * A message body response normally has no rows of its own. Keep the selected message in its + * desktop list alongside only cached siblings that prove the same account and mailbox identity. + * Without both relation IDs, do not guess a mailbox and retain just the selected message. + */ +private fun List.visibleMailSiblings( + selected: NativeMailWorkspaceItem, +): List { + val selectedAccountIds = selected.record.mailWorkspaceAccountIds(selected.presentation.kind) + val selectedMailboxIds = selected.record.mailWorkspaceMailboxIds(selected.presentation.kind) + if (selectedAccountIds.isEmpty() || selectedMailboxIds.isEmpty()) return listOf(selected) + val siblings = filter { candidate -> + candidate.presentation.kind == NativeMailboxItemKind.Message && + candidate.record.mailWorkspaceAccountIds(candidate.presentation.kind) + .intersect(selectedAccountIds) + .isNotEmpty() && + candidate.record.mailWorkspaceMailboxIds(candidate.presentation.kind) + .intersect(selectedMailboxIds) + .isNotEmpty() + } + return siblings +} + +private fun NativeRecord.mailWorkspaceAccountIds(kind: NativeMailboxItemKind): Set = buildSet { + mailWorkspaceRelationValue("accountid", "mailaccountid")?.let(::add) + if (kind == NativeMailboxItemKind.Account) { + add(id) + mailWorkspaceRelationValue("databaseid", "id")?.let(::add) + } +} + +private fun NativeRecord.mailWorkspaceMailboxIds(kind: NativeMailboxItemKind): Set = buildSet { + mailWorkspaceRelationValue("mailboxid", "folderid", "mailfolderid")?.let(::add) + if (kind == NativeMailboxItemKind.Folder) { + add(id) + mailWorkspaceRelationValue("databaseid", "id")?.let(::add) + } +} + +private fun NativeRecord.mailWorkspaceRelationValue(vararg names: String): String? { + val normalizedNames = names.mapTo(hashSetOf()) { name -> name.mailSemanticKey() } + return sequenceOf(values, displayValues, bindingContext) + .flatMap { fields -> fields.entries.asSequence() } + .firstOrNull { (key, value) -> + key.mailSemanticKey() in normalizedNames && !value.isNullOrBlank() + } + ?.value +} + +private fun NativeRecord.mailboxHierarchyDepth(): Int { + val semanticValues = values.entries.associate { (key, value) -> key.mailSemanticKey() to value } + val path = listOf("path", "mailboxpath", "fullpath", "displaypath") + .firstNotNullOfOrNull { key -> semanticValues[key]?.takeIf { value -> !value.isNullOrBlank() } } + ?: return if ( + listOf("parentid", "parentmailboxid", "parentfolderid") + .any { key -> !semanticValues[key].isNullOrBlank() } + ) { + 1 + } else { + 0 + } + return path.split('/', '\\').count(String::isNotBlank).minus(1).coerceIn(0, 4) +} + +private fun NativeMailWorkspaceItem.mailboxHierarchySortKey(): String { + val semanticValues = record.values.entries.associate { (key, value) -> key.mailSemanticKey() to value } + return listOf("path", "mailboxpath", "fullpath", "displaypath") + .firstNotNullOfOrNull { key -> semanticValues[key]?.takeIf { value -> !value.isNullOrBlank() } } + ?.lowercase() + ?: presentation.title.lowercase() +} + +private fun NativeMailWorkspaceItem.inboxScore(): Int { + val semanticValues = record.values.entries.associate { (key, value) -> key.mailSemanticKey() to value } + val role = listOf("specialuse", "specialrole", "role", "type") + .firstNotNullOfOrNull { key -> semanticValues[key] } + ?.mailSemanticKey() + val title = presentation.title.mailSemanticKey() + return when { + role == "inbox" -> 100 + title == "inbox" -> 80 + title.endsWith("inbox") -> 60 + else -> 0 + } +} + +private fun String.mailSemanticKey(): String = lowercase().filter(Char::isLetterOrDigit) + +private fun String.mailSemanticWords(): List { + val separated = replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .lowercase() + .split(Regex("[^a-z0-9]+")) + .filter(String::isNotBlank) + return separated + separated.joinToString("") +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt index 81cb8bc4..a5787b65 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt @@ -27,6 +27,7 @@ internal data class NativeMailboxPresentation( val timestamp: String?, val unread: Boolean, val unreadCount: Int?, + val threadSize: Int?, val flagged: Boolean, val attachmentCount: Int, ) @@ -47,6 +48,42 @@ internal data class NativeMailMessageRenderTarget( val presentation: NativeMailMessageDetailPresentation, ) +/** + * Keeps Mail sender labels focused on the person instead of repeating an address that is already + * available in message details. Unknown envelope formats remain unchanged rather than guessed. + */ +internal fun nativeMailSenderLabel(sender: String?): String? { + val trimmed = sender?.trim()?.takeIf(String::isNotBlank) ?: return null + val displayName = MAIL_SENDER_WITH_ADDRESS.matchEntire(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.trim('"') + ?.takeIf(String::isNotBlank) + return displayName ?: trimmed +} + +/** + * Formats the common ISO envelope timestamp compactly for message rows without depending on the + * device locale. Other server-provided formats are preserved verbatim. + */ +internal fun nativeMailTimestampLabel(timestamp: String?): String? { + val trimmed = timestamp?.trim()?.takeIf(String::isNotBlank) ?: return null + val match = MAIL_ISO_TIMESTAMP.matchEntire(trimmed) ?: return trimmed + val monthIndex = match.groupValues[2].toIntOrNull() ?: return trimmed + val month = MAIL_MONTH_NAMES.getOrNull(monthIndex - 1) ?: return trimmed + val day = match.groupValues[3].toIntOrNull() ?: return trimmed + return "$month $day, ${match.groupValues[4]}:${match.groupValues[5]}" +} + +private val MAIL_SENDER_WITH_ADDRESS = Regex("^\\s*(.*?)\\s*<[^<>]+>\\s*$") +private val MAIL_ISO_TIMESTAMP = Regex( + "^(\\d{4})-(\\d{2})-(\\d{2})[Tt ](\\d{2}):(\\d{2})(?::\\d{2}(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:?\\d{2})?$", +) +private val MAIL_MONTH_NAMES = listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +) + internal enum class NativeMediaItemKind { Artist, Album, @@ -201,6 +238,8 @@ internal fun nativeMailboxPresentation( ?.takeIf(String::isNotBlank) val timestamp = values.formattedTimestamp() val flagged = values.boolean("flagged", "starred", "favorite", "favourite") == true || "flagged" in flags + val threadSize = values.int("messagecount", "messagescount", "threadsize", "threadcount") + ?: values.arraySize("messages") val attachmentCount = values.int("attachmentcount", "attachmentscount") ?: values.arraySize("attachments") ?: if (values.boolean("hasattachments", "hasattachment") == true) 1 else 0 @@ -212,6 +251,7 @@ internal fun nativeMailboxPresentation( timestamp = timestamp, unread = unread, unreadCount = unreadCount, + threadSize = threadSize?.takeIf { count -> kind == NativeMailboxItemKind.Message && count > 1 }, flagged = flagged, attachmentCount = attachmentCount, ) @@ -226,9 +266,9 @@ internal fun nativeMailMessageDetailPresentation( val words = semanticTokens(resource.id, resource.name) val messageShape = values.hasAny("subject") && values.hasAny("from", "sender", "author", "fromemail") && - values.hasAny("body", "content", "text", "htmlbody") + values.hasAny("body", "bodyhtml", "bodyplain", "content", "htmlbody", "messagebody", "text") if (!messageShape && words.none { it in MESSAGE_WORDS }) return null - val body = values.string("body", "content", "text", "htmlbody") + val body = values.string("body", "bodyhtml", "bodyplain", "content", "htmlbody", "messagebody", "text") if (body.isNullOrBlank() && !messageShape) return null return NativeMailMessageDetailPresentation( subject = values.string("subject", "title") ?: "(No subject)", @@ -236,13 +276,55 @@ internal fun nativeMailMessageDetailPresentation( recipients = values.string("to", "recipients", "recipient"), timestamp = values.formattedTimestamp(), body = body, - htmlBody = values.boolean("hashtmlbody", "html", "ishtml") == true, + htmlBody = values.boolean("hashtmlbody", "html", "ishtml") == true || + values.hasAny("bodyhtml", "htmlbody"), attachmentCount = values.arraySize("attachments") ?: values.int("attachmentcount", "attachmentscount") ?: 0, ) } +/** Converts a bounded nested thread response into independently renderable message bodies. */ +internal fun nativeMailThreadPresentations( + resource: ResourceSpec, + record: NativeRecord, +): List { + val threadItems = record.structuredValues.entries.firstNotNullOfOrNull { (key, value) -> + if (key.semanticKey() !in setOf("messages", "threadmessages", "conversationmessages")) { + return@firstNotNullOfOrNull null + } + value as? NativeStructuredValue.ListValue + } ?: return emptyList() + return threadItems.items.mapIndexedNotNull { index, item -> + val message = item as? NativeStructuredValue.ObjectValue ?: return@mapIndexedNotNull null + val values = mutableMapOf() + val structuredValues = mutableMapOf() + message.entries.forEach { entry -> + when (val value = entry.value) { + is NativeStructuredValue.Scalar -> values[entry.key] = value.value + is NativeStructuredValue.ListValue, + is NativeStructuredValue.ObjectValue, + -> structuredValues[entry.key] = value + } + } + val id = listOf("id", "messageid", "uid") + .firstNotNullOfOrNull { alias -> + values.entries.firstOrNull { (key, _) -> key.semanticKey() == alias }?.value + } + ?.takeIf(String::isNotBlank) + ?: "thread-message-$index" + nativeMailMessageDetailPresentation( + resource = resource, + record = NativeRecord( + id = id, + values = values, + structuredValues = structuredValues, + actionSafeIdentity = false, + ), + ) + } +} + /** * Rejoins a message envelope with a separately fetched body/detail facet. * diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt index 39e0b615..18cdca37 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt @@ -3,6 +3,7 @@ package dev.obiente.nextcloudnative.app import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.time.Duration.Companion.minutes @@ -32,6 +33,100 @@ class DynamicNativeMemoryCacheTest { assertNull(first.account.takeIf { it.contains("never-cache-this") }) } + @Test + fun `screen cache distinguishes mailbox snapshots with overlapping mailbox ids`() { + val personal = NativeRecord( + id = "inbox", + values = mapOf("accountId" to "personal", "mailboxId" to "inbox"), + ) + val work = personal.copy(values = personal.values + ("accountId" to "work")) + + val personalKey = dynamicScreenCacheKey( + session = session, + appId = "mail", + viewId = "messages.list", + selectedRecordId = personal.id, + parameterValues = emptyMap(), + selectedRecordResourceId = "mailboxes", + selectedRecordScope = personal.dynamicScreenCacheScope(), + ) + val workKey = dynamicScreenCacheKey( + session = session, + appId = "mail", + viewId = "messages.list", + selectedRecordId = work.id, + parameterValues = emptyMap(), + selectedRecordResourceId = "mailboxes", + selectedRecordScope = work.dynamicScreenCacheScope(), + ) + + assertNotEquals(personalKey, workKey) + assertNotEquals( + dynamicScreenSelectionIdentity( + resourceId = "mailboxes", + recordId = personal.id, + recordScope = personal.dynamicScreenCacheScope(), + ), + dynamicScreenSelectionIdentity( + resourceId = "mailboxes", + recordId = work.id, + recordScope = work.dynamicScreenCacheScope(), + ), + ) + } + + @Test + fun `pagination completion is rejected after a Mail account selection changes`() { + val personal = NativeRecord( + id = "inbox", + values = mapOf("accountId" to "personal", "mailboxId" to "inbox"), + ) + val work = personal.copy(values = personal.values + ("accountId" to "work")) + fun identity(record: NativeRecord) = dynamicPaginationRequestIdentity( + session = session, + appId = "mail", + viewId = "messages.list", + resourceId = "messages", + selection = dynamicScreenSelectionIdentity( + resourceId = "mailboxes", + recordId = record.id, + recordScope = record.dynamicScreenCacheScope(), + ), + pathParameters = mapOf("mailboxId" to record.id), + cacheable = true, + ) + + val outstandingPersonalPage = identity(personal) + val activeWorkSelection = identity(work) + + assertFalse(outstandingPersonalPage.isCurrentDynamicPaginationRequest(activeWorkSelection)) + assertNotEquals( + personal.dynamicPaginationRecordIdentity("messages"), + work.dynamicPaginationRecordIdentity("messages"), + ) + } + + @Test + fun `uncacheable sparse selection never stores or returns a screen`() { + val cache = DynamicNativeMemoryCache() + val key = dynamicScreenCacheKey( + session = session, + appId = "mail", + viewId = "messages.list", + selectedRecordId = "inbox", + parameterValues = emptyMap(), + selectedRecordResourceId = "mailboxes", + cacheable = false, + ) + + cache.storeScreen( + key, + DynamicScreenSnapshot(listOf(NativeRecord("stale", emptyMap())), emptyMap()), + ) + + assertNull(cache.screen(key)) + } + @Test fun `screen cache is bounded and isolated by account`() { val cache = DynamicNativeMemoryCache(maximumScreens = 1) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkspaceTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkspaceTest.kt new file mode 100644 index 00000000..eb572690 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkspaceTest.kt @@ -0,0 +1,771 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.DynamicAction +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor +import dev.obiente.nextcloudnative.nativeui.model.DynamicForm +import dev.obiente.nextcloudnative.nativeui.model.DynamicHttpBinding +import dev.obiente.nextcloudnative.nativeui.model.DynamicResource +import dev.obiente.nextcloudnative.nativeui.model.EndpointPolicy +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.HttpParameter +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.ParameterSource +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NativeMailWorkspaceTest { + @Test + fun `semantic mail datasets become account mailbox and message panes`() { + val account = resource("accounts", "Accounts") + val mailboxes = resource("mailboxes", "Mailboxes") + val messages = resource("messages", "Messages") + val schema = schema(account, mailboxes, messages) + val accountRecord = NativeRecord( + id = "personal", + values = mapOf("accountName" to "Personal", "emailAddress" to "me@example.test"), + ) + val inboxRecord = NativeRecord( + id = "inbox", + values = mapOf( + "name" to "Inbox", + "specialUse" to "inbox", + "unreadCount" to "3", + "path" to "Personal/Inbox", + ), + ) + val messageRecord = NativeRecord( + id = "42", + values = mapOf( + "subject" to "Release checklist", + "from" to "Ada ", + "preview" to "The build is ready.", + "seen" to "false", + ), + ) + + val plan = nativeMailWorkspacePlan( + schema = schema, + currentResource = messages, + currentRecords = listOf(messageRecord), + context = NativeDatasetContext( + parentResourceId = "mailboxes", + parentRecord = inboxRecord, + relatedRecords = mapOf( + "accounts" to listOf(accountRecord), + "mailboxes" to listOf(inboxRecord), + ), + ), + selectedRecordId = "42", + // Dynamic navigation records the source resource with the selected record. Keeping + // that scope in this fixture avoids treating an ID-only restored selection as safe. + selectedRecordResourceId = messages.id, + ) + + assertTrue(plan.hasMailData) + assertEquals(listOf("Personal"), plan.accounts.map { it.presentation.title }) + assertEquals("Inbox", plan.preferredInbox?.presentation?.title) + assertEquals(0, plan.folders.single().hierarchyDepth) + assertEquals("Release checklist", plan.messages.single().presentation.title) + assertEquals("42", plan.selectedMessage?.record?.id) + assertEquals("inbox", plan.selectedContainer?.record?.id) + assertEquals(listOf("42"), plan.visibleMessages.map { item -> item.record.id }) + assertEquals("accounts", plan.accounts.single().record.effectiveNativeResourceId("messages")) + assertEquals("mailboxes", plan.folders.single().record.effectiveNativeResourceId("messages")) + assertEquals("messages", plan.messages.single().record.effectiveNativeResourceId("messages")) + } + + @Test + fun `multi account landing does not expose cached messages before an account is selected`() { + val accounts = resource("accounts", "Accounts") + val messages = resource("messages", "Messages") + val plan = nativeMailWorkspacePlan( + schema = schema(accounts, messages), + currentResource = accounts, + currentRecords = listOf( + NativeRecord("personal", mapOf("accountName" to "Personal")), + NativeRecord("work", mapOf("accountName" to "Work")), + ), + context = NativeDatasetContext( + relatedRecords = mapOf( + "messages" to listOf( + NativeRecord( + id = "42", + values = mapOf( + "subject" to "Cached subject", + "from" to "Ada ", + ), + ), + ), + ), + ), + selectedRecordId = null, + ) + + assertEquals(2, plan.accounts.size) + assertEquals(1, plan.messages.size) + assertTrue(plan.visibleMessages.isEmpty()) + } + + @Test + fun `selected mailbox filters cached rows by mailbox and account when mailbox ids overlap`() { + val mailboxes = resource("mailboxes", "Mailboxes") + val messages = resource("messages", "Messages") + val personalInbox = NativeRecord( + id = "inbox", + values = mapOf("name" to "Inbox", "accountId" to "personal"), + ) + val workInbox = personalInbox.copy(values = personalInbox.values + ("accountId" to "work")) + val plan = nativeMailWorkspacePlan( + schema = schema(mailboxes, messages), + currentResource = mailboxes, + currentRecords = listOf(personalInbox, workInbox), + context = NativeDatasetContext( + parentResourceId = "mailboxes", + parentRecord = personalInbox, + relatedRecords = mapOf( + "messages" to listOf( + message(id = "same-id", accountId = "personal", mailboxId = "inbox", subject = "Personal"), + message(id = "same-id", accountId = "work", mailboxId = "inbox", subject = "Work"), + message(id = "archive", accountId = "personal", mailboxId = "archive", subject = "Archive"), + ), + ), + ), + selectedRecordId = personalInbox.id, + selectedRecordResourceId = mailboxes.id, + ) + + assertEquals("personal", plan.selectedContainer?.record?.values?.get("accountId")) + assertEquals(listOf("Personal"), plan.visibleMessages.map { item -> item.presentation.title }) + } + + @Test + fun `overlapping mailbox ids have distinct rail identities and a single selected item`() { + val mailboxes = resource("mailboxes", "Mailboxes") + val messages = resource("messages", "Messages") + val personalInbox = NativeRecord( + id = "inbox", + values = mapOf("name" to "Inbox", "accountId" to "personal"), + ) + val workInbox = personalInbox.copy(values = personalInbox.values + ("accountId" to "work")) + val plan = nativeMailWorkspacePlan( + schema = schema(mailboxes, messages), + currentResource = mailboxes, + currentRecords = listOf(personalInbox, workInbox), + context = NativeDatasetContext( + parentResourceId = mailboxes.id, + parentRecord = personalInbox, + ), + selectedRecordId = personalInbox.id, + selectedRecordResourceId = mailboxes.id, + ) + + val selectedKey = plan.selectedContainer?.nativeMailWorkspaceRecordKey() + val railKeys = plan.folders.map { item -> item.nativeMailWorkspaceRecordKey() } + + assertEquals(2, railKeys.toSet().size) + assertEquals(1, railKeys.count { key -> key == selectedKey }) + assertNotEquals(railKeys[0], railKeys[1]) + } + + @Test + fun `sparse mail selections fail closed until an account scope is present`() { + val accounts = resource("accounts", "Accounts") + val mailboxes = resource("mailboxes", "Mailboxes") + val messages = resource("messages", "Messages") + val schema = schema(accounts, mailboxes, messages) + + assertFalse( + nativeMailScreenCacheScopeIsSafe( + schema, + mailboxes.id, + NativeRecord("inbox", mapOf("name" to "Inbox")), + ), + ) + assertTrue( + nativeMailScreenCacheScopeIsSafe( + schema, + mailboxes.id, + NativeRecord("inbox", mapOf("name" to "Inbox", "accountId" to "personal")), + ), + ) + assertTrue( + nativeMailScreenCacheScopeIsSafe( + schema, + accounts.id, + NativeRecord("personal", mapOf("accountName" to "Personal")), + ), + ) + assertFalse( + nativeMailScreenCacheScopeIsSafe( + schema, + messages.id, + NativeRecord("42", mapOf("subject" to "Missing relations", "from" to "Ada")), + ), + ) + } + + @Test + fun `inbox and archive cached messages remain isolated`() { + val mailboxes = resource("mailboxes", "Mailboxes") + val messages = resource("messages", "Messages") + val inbox = NativeRecord( + id = "inbox", + values = mapOf("name" to "Inbox", "accountId" to "personal"), + ) + val archive = NativeRecord( + id = "archive", + values = mapOf("name" to "Archive", "accountId" to "personal"), + ) + val plan = nativeMailWorkspacePlan( + schema = schema(mailboxes, messages), + currentResource = mailboxes, + currentRecords = listOf(inbox, archive), + context = NativeDatasetContext( + parentResourceId = "mailboxes", + parentRecord = archive, + relatedRecords = mapOf( + "messages" to listOf( + message(id = "inbox-message", accountId = "personal", mailboxId = "inbox", subject = "Inbox only"), + message(id = "archive-message", accountId = "personal", mailboxId = "archive", subject = "Archive only"), + ), + ), + ), + selectedRecordId = archive.id, + selectedRecordResourceId = mailboxes.id, + ) + + assertEquals(listOf("Archive only"), plan.visibleMessages.map { item -> item.presentation.title }) + } + + @Test + fun `orphaned and account-ambiguous cached rows stay hidden from a selected mailbox`() { + val mailboxes = resource("mailboxes", "Mailboxes") + val messages = resource("messages", "Messages") + val inbox = NativeRecord( + id = "inbox", + values = mapOf("name" to "Inbox", "accountId" to "personal"), + ) + val plan = nativeMailWorkspacePlan( + schema = schema(mailboxes, messages), + currentResource = mailboxes, + currentRecords = listOf(inbox), + context = NativeDatasetContext( + parentResourceId = "mailboxes", + parentRecord = inbox, + relatedRecords = mapOf( + "messages" to listOf( + NativeRecord( + id = "orphan", + values = mapOf("subject" to "No mailbox", "from" to "Ada "), + ), + message(id = "no-account", accountId = null, mailboxId = "inbox", subject = "Unknown account"), + message(id = "wrong-account", accountId = "work", mailboxId = "inbox", subject = "Wrong account"), + ), + ), + ), + selectedRecordId = inbox.id, + selectedRecordResourceId = mailboxes.id, + ) + + assertTrue(plan.visibleMessages.isEmpty()) + } + + @Test + fun `message selection uses resource and account instead of record id alone`() { + val mailboxes = resource("mailboxes", "Mailboxes") + val messages = resource("messages", "Messages") + val threads = resource("threads", "Threads") + val personalMessage = message( + id = "42", + accountId = "personal", + mailboxId = "inbox", + subject = "Personal resource message", + ) + val workMessage = message( + id = "42", + accountId = "work", + mailboxId = "inbox", + subject = "Work resource message", + ) + val threadWithSameId = message( + id = "42", + accountId = "personal", + mailboxId = "inbox", + subject = "Thread with same id", + ) + val plan = nativeMailWorkspacePlan( + schema = schema(mailboxes, messages, threads), + currentResource = mailboxes, + currentRecords = emptyList(), + context = NativeDatasetContext( + parentResourceId = "messages", + parentRecord = workMessage, + relatedRecords = mapOf( + "messages" to listOf(personalMessage, workMessage), + "threads" to listOf(threadWithSameId), + ), + ), + selectedRecordId = workMessage.id, + selectedRecordResourceId = "messages", + ) + + assertEquals("Work resource message", plan.selectedMessage?.presentation?.title) + assertEquals(listOf("Work resource message"), plan.visibleMessages.map { item -> item.presentation.title }) + } + + @Test + fun `record id only selection stays empty when cached mail identities are ambiguous`() { + val messages = resource("messages", "Messages") + val plan = nativeMailWorkspacePlan( + schema = schema(messages), + currentResource = messages, + currentRecords = emptyList(), + context = NativeDatasetContext( + relatedRecords = mapOf( + "messages" to listOf( + message(id = "42", accountId = "personal", mailboxId = "inbox", subject = "Personal"), + message(id = "42", accountId = "work", mailboxId = "inbox", subject = "Work"), + ), + ), + ), + selectedRecordId = "42", + ) + + assertNull(plan.selectedMessage) + assertTrue(plan.visibleMessages.isEmpty()) + } + + @Test + fun `message body facet keeps the parent message selected`() { + val messages = resource("messages", "Messages") + val body = resource("messageBody", "Message body") + val schema = schema(messages, body) + val parentMessage = NativeRecord( + id = "42", + values = mapOf( + "subject" to "Release checklist", + "from" to "Ada ", + ), + ) + + val bodyRecord = NativeRecord( + id = "body-42", + values = mapOf("body" to "

The build is ready.

", "hasHtmlBody" to "true"), + ) + val context = NativeDatasetContext( + parentResourceId = "messages", + parentRecord = parentMessage, + relatedRecords = mapOf("messages" to listOf(parentMessage)), + ) + val plan = nativeMailWorkspacePlan( + schema = schema, + currentResource = body, + currentRecords = listOf(bodyRecord), + context = context, + selectedRecordId = "body-42", + ) + + assertEquals("42", plan.selectedMessage?.record?.id) + assertEquals("Release checklist", plan.selectedMessage?.presentation?.title) + assertTrue(plan.currentItems.isEmpty()) + assertEquals(listOf("42"), plan.messages.map { item -> item.record.id }) + assertEquals(listOf("42"), plan.visibleMessages.map { item -> item.record.id }) + assertTrue(plan.hasMailData) + val detail = nativeMailWorkspaceDetailTarget( + schema = schema, + currentResource = body, + currentRecords = listOf(bodyRecord), + context = context, + selectedMessage = plan.selectedMessage, + ) + assertEquals("42", detail?.record?.id) + assertEquals("

The build is ready.

", detail?.presentation?.body) + } + + @Test + fun `message body facet keeps verified siblings from only the selected mailbox`() { + val messages = resource("messages", "Messages") + val body = resource("messageBody", "Message body") + val schema = schema(messages, body) + val selected = message("mail-1", "personal", "inbox", "Selected message") + val sameMailbox = message("mail-2", "personal", "inbox", "Inbox sibling") + val otherMailbox = message("mail-3", "personal", "archive", "Archive message") + val otherAccount = message("mail-4", "work", "inbox", "Work inbox message") + val context = NativeDatasetContext( + parentResourceId = messages.id, + parentRecord = selected, + relatedRecords = mapOf( + messages.id to listOf(selected, sameMailbox, otherMailbox, otherAccount), + ), + ) + + val plan = nativeMailWorkspacePlan( + schema = schema, + currentResource = body, + currentRecords = listOf( + NativeRecord("body-1", mapOf("body" to "Selected body", "hasHtmlBody" to "false")), + ), + context = context, + selectedRecordId = selected.id, + selectedRecordResourceId = messages.id, + ) + + assertEquals( + setOf(selected.id, sameMailbox.id), + plan.visibleMessages.map { item -> item.record.id }.toSet(), + ) + assertFalse(plan.visibleMessages.any { item -> item.record.id in setOf(otherMailbox.id, otherAccount.id) }) + } + + @Test + fun `current mailbox response replaces stale related rows for the same resource`() { + val messages = resource("messages", "Messages") + val current = NativeRecord( + id = "42", + values = mapOf("subject" to "Current subject", "from" to "Ada "), + ) + val stale = current.copy(values = current.values + ("subject" to "Stale subject")) + + val plan = nativeMailWorkspacePlan( + schema = schema(messages), + currentResource = messages, + currentRecords = listOf(current), + context = NativeDatasetContext(relatedRecords = mapOf("messages" to listOf(stale))), + selectedRecordId = null, + ) + + assertEquals(listOf("Current subject"), plan.messages.map { item -> item.presentation.title }) + } + + @Test + fun `sole account and unique inbox provide a useful automatic landing`() { + val accounts = resource("accounts", "Accounts") + val mailboxes = resource("mailboxes", "Mailboxes") + val account = NativeRecord( + id = "personal", + values = mapOf("accountName" to "Personal", "emailAddress" to "me@example.test"), + ) + val inbox = NativeRecord( + id = "inbox", + values = mapOf("name" to "Inbox", "specialUse" to "inbox"), + ) + val archive = NativeRecord( + id = "archive", + values = mapOf("name" to "Archive", "specialUse" to "archive"), + ) + + assertEquals(account, nativeMailSoleAccountLandingRecord(accounts, listOf(account))) + assertEquals(inbox, nativeMailInboxLandingRecord(mailboxes, listOf(archive, inbox))) + assertNull(nativeMailSoleAccountLandingRecord(accounts, listOf(account, account.copy(id = "work")))) + assertNull( + nativeMailInboxLandingRecord( + mailboxes, + listOf(inbox, inbox.copy(id = "second-inbox")), + ), + ) + val schema = schema(accounts, mailboxes) + assertTrue(isNativeMailContainerRecord(schema, "accounts", account)) + assertTrue(isNativeMailContainerRecord(schema, "mailboxes", inbox)) + } + + @Test + fun `mailbox hierarchy removes the account prefix and keeps children with their parent`() { + val mailboxes = resource("mailboxes", "Mailboxes") + val records = listOf( + NativeRecord("archive", mapOf("name" to "Archive", "path" to "Personal/Archive")), + NativeRecord("project-child", mapOf("name" to "Native", "path" to "Personal/Projects/Native")), + NativeRecord("projects", mapOf("name" to "Projects", "path" to "Personal/Projects")), + ) + + val plan = nativeMailWorkspacePlan( + schema = schema(mailboxes), + currentResource = mailboxes, + currentRecords = records, + context = NativeDatasetContext(), + selectedRecordId = null, + ) + + assertEquals(listOf("Archive", "Projects", "Native"), plan.folders.map { it.presentation.title }) + assertEquals(listOf(0, 0, 1), plan.folders.map { it.hierarchyDepth }) + } + + @Test + fun `ambiguous non mail datasets do not opt into mail workspace`() { + val inventory = resource("inventory", "Inventory") + val schema = schema(inventory) + val record = NativeRecord( + id = "tripod", + values = mapOf("name" to "Tripod", "category" to "Camera"), + ) + + val plan = nativeMailWorkspacePlan( + schema = schema, + currentResource = inventory, + currentRecords = listOf(record), + context = NativeDatasetContext(), + selectedRecordId = null, + ) + + assertFalse(plan.hasMailData) + assertTrue(plan.currentItems.isEmpty()) + assertNull(plan.preferredInbox) + } + + @Test + fun `parameter free semantic compose form becomes the workspace compose entry`() { + val messages = resource("messages", "Messages") + val action = nativeAction("send-message", "Send message", "messages") + val descriptor = descriptor( + actions = listOf(action), + forms = listOf( + DynamicForm( + id = "compose-message", + title = "Compose", + resourceId = "messages", + actionId = action.id, + confidence = Confidence.verified, + ), + ), + ) + val schema = schemaWithActions( + resources = listOf(messages), + actions = listOf(schemaAction(action)), + ) + + val compose = descriptor.preferredNativeMailComposeAction(schema) + + assertEquals("compose-message", compose?.formId) + assertEquals("send-message", compose?.actionId) + } + + @Test + fun `automatic landing requires the complete semantic mail hierarchy`() { + val mailDescriptor = descriptor( + resources = listOf( + dynamicResource("accounts", "Accounts"), + dynamicResource("mailboxes", "Mailboxes"), + dynamicResource("messages", "Messages"), + ), + actions = emptyList(), + forms = emptyList(), + ) + val financeDescriptor = descriptor( + resources = listOf(dynamicResource("accounts", "Expense accounts")), + actions = emptyList(), + forms = emptyList(), + ) + + assertTrue(mailDescriptor.hasNativeMailWorkspaceSemantics()) + assertTrue( + schema( + resource("accounts", "Accounts"), + resource("mailboxes", "Mailboxes"), + resource("messages", "Messages"), + ).hasNativeMailWorkspaceSemantics(), + ) + assertFalse(financeDescriptor.hasNativeMailWorkspaceSemantics()) + assertFalse(schema(resource("accounts", "Expense accounts")).hasNativeMailWorkspaceSemantics()) + } + + @Test + fun `workspace state follows account mailbox message and body context`() { + val accounts = resource("accounts", "Accounts") + val mailboxes = resource("mailboxes", "Mailboxes") + val messages = resource("threads", "Threads") + val body = resource("content", "Message body") + val schema = schema(accounts, mailboxes, messages, body) + val parentMessage = NativeRecord( + id = "thread-42", + values = mapOf("subject" to "Release checklist", "from" to "Ada "), + ) + + assertEquals( + NativeMailWorkspaceSection.Accounts, + nativeMailWorkspaceSection(schema, accounts, NativeDatasetContext()), + ) + assertEquals( + NativeMailWorkspaceSection.Mailboxes, + nativeMailWorkspaceSection(schema, mailboxes, NativeDatasetContext()), + ) + assertEquals( + NativeMailWorkspaceSection.Messages, + nativeMailWorkspaceSection(schema, messages, NativeDatasetContext()), + ) + assertEquals( + NativeMailWorkspaceSection.MessageDetail, + nativeMailWorkspaceSection( + schema, + body, + NativeDatasetContext(parentResourceId = "threads", parentRecord = parentMessage), + ), + ) + } + + @Test + fun `ambiguous compose routes are not guessed`() { + val messages = resource("messages", "Messages") + val actions = listOf( + nativeAction("compose-message", "Compose message", "messages"), + nativeAction("compose-email", "Compose email", "messages"), + ) + val descriptor = descriptor( + actions = actions, + forms = actions.map { action -> + DynamicForm( + id = "form-${action.id}", + title = action.label, + resourceId = action.resourceId, + actionId = action.id, + confidence = Confidence.verified, + ) + }, + ) + val schema = schemaWithActions( + resources = listOf(messages), + actions = actions.map(::schemaAction), + ) + + assertNull(descriptor.preferredNativeMailComposeAction(schema)) + } + + @Test + fun `compose entry does not guess a missing route parameter`() { + val messages = resource("messages", "Messages") + val action = nativeAction("compose-message", "Compose", "messages").copy( + binding = DynamicHttpBinding( + method = HttpMethod.POST, + path = "/semantic/accounts/{accountId}/messages", + pathParameters = listOf( + HttpParameter( + name = "accountId", + required = true, + schema = JsonPrimitive("string"), + source = ParameterSource.runtimeContext, + ), + ), + ), + ) + val descriptor = descriptor( + actions = listOf(action), + forms = listOf( + DynamicForm( + id = "compose-message", + title = "Compose", + resourceId = "messages", + actionId = action.id, + confidence = Confidence.verified, + ), + ), + ) + + assertNull( + descriptor.preferredNativeMailComposeAction( + schemaWithActions(listOf(messages), listOf(schemaAction(action))), + ), + ) + } + + private fun message( + id: String, + accountId: String?, + mailboxId: String, + subject: String, + ): NativeRecord = NativeRecord( + id = id, + values = buildMap { + put("subject", subject) + put("from", "Ada ") + put("mailboxId", mailboxId) + accountId?.let { value -> put("accountId", value) } + }, + ) + + private fun resource(id: String, name: String): ResourceSpec = ResourceSpec( + id = id, + name = name, + confidence = Confidence.verified, + ) + + private fun schema(vararg resources: ResourceSpec): NativeAppSchema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity(id = "semantic-mail-fixture", name = "Mail fixture", version = "1"), + confidence = Confidence.verified, + resources = resources.toList(), + ) + + private fun schemaWithActions( + resources: List, + actions: List, + ): NativeAppSchema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity(id = "semantic-mail-fixture", name = "Mail fixture", version = "1"), + confidence = Confidence.verified, + resources = resources, + actions = actions, + ) + + private fun descriptor( + resources: List = emptyList(), + actions: List, + forms: List, + ): DynamicAppDescriptor = DynamicAppDescriptor( + descriptorVersion = "1", + app = AppIdentity(id = "semantic-mail-fixture", name = "Mail fixture", version = "1"), + endpointPolicy = EndpointPolicy(serverOrigin = "https://cloud.example.test"), + resources = resources, + actions = actions, + forms = forms, + ) + + private fun dynamicResource(id: String, label: String): DynamicResource = DynamicResource( + id = id, + label = label, + collection = true, + confidence = Confidence.verified, + ) + + private fun nativeAction( + id: String, + label: String, + resourceId: String, + ): DynamicAction = DynamicAction( + id = id, + label = label, + resourceId = resourceId, + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + binding = DynamicHttpBinding( + method = HttpMethod.POST, + path = "/semantic/messages", + ), + confidence = Confidence.verified, + ) + + private fun schemaAction(action: DynamicAction): ActionSpec = ActionSpec( + id = action.id, + label = action.label, + resourceId = action.resourceId, + binding = ApiBinding( + method = action.binding.method, + path = action.binding.path, + operationId = action.id, + ), + intent = action.intent, + risk = action.risk, + requiresConfirmation = action.requiresConfirmation, + confidence = action.confidence, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentationsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentationsTest.kt index ec5e9524..a6c0090c 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentationsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentationsTest.kt @@ -38,6 +38,14 @@ class NativeSemanticPresentationsTest { assertTrue(presentation.unread) } + @Test + fun `mail row labels prefer a sender name and compact ISO timestamp`() { + assertEquals("Ada", nativeMailSenderLabel("Ada ")) + assertEquals("plain@example.test", nativeMailSenderLabel("plain@example.test")) + assertEquals("Jul 29, 08:42", nativeMailTimestampLabel("2026-07-29T08:42:00Z")) + assertEquals("Server time unknown", nativeMailTimestampLabel("Server time unknown")) + } + @Test fun `mailbox counts become folder badges without treating zero as unread`() { val resource = resource("mailboxes", "Mailboxes", "name", "unread") @@ -56,6 +64,24 @@ class NativeSemanticPresentationsTest { assertFalse(empty.unread) } + @Test + fun `mail thread rows retain their message count`() { + val presentation = nativeMailboxPresentation( + resource("threads", "Threads", "subject", "from", "messageCount"), + NativeRecord( + id = "thread-42", + values = mapOf( + "subject" to "Release checklist", + "from" to "Ada ", + "messageCount" to "4", + ), + ), + ) + + assertEquals(NativeMailboxItemKind.Message, presentation.kind) + assertEquals(4, presentation.threadSize) + } + @Test fun `standard envelope and body fields become a native message detail`() { val presentation = nativeMailMessageDetailPresentation( @@ -82,6 +108,42 @@ class NativeSemanticPresentationsTest { assertTrue(presentation?.htmlBody == true) } + @Test + fun `bounded nested thread messages become individual native details`() { + fun scalar(value: String) = NativeStructuredValue.Scalar( + value = value, + kind = NativeStructuredScalarKind.string, + ) + fun message(id: String, sender: String, body: String) = NativeStructuredValue.ObjectValue( + entries = listOf( + NativeStructuredEntry("id", "Id", scalar(id)), + NativeStructuredEntry("subject", "Subject", scalar("Release checklist")), + NativeStructuredEntry("from", "From", scalar(sender)), + NativeStructuredEntry("body", "Body", scalar(body)), + NativeStructuredEntry("hasHtmlBody", "HTML", scalar("true")), + ), + ) + val presentations = nativeMailThreadPresentations( + resource("threads", "Threads", "subject", "from", "messages"), + NativeRecord( + id = "thread-42", + values = mapOf("subject" to "Release checklist"), + structuredValues = mapOf( + "messages" to NativeStructuredValue.ListValue( + items = listOf( + message("41", "Ada ", "

Ready for review.

"), + message("42", "Mira ", "

I will review it.

"), + ), + ), + ), + ), + ) + + assertEquals(2, presentations.size) + assertEquals("Ada ", presentations.first().sender) + assertEquals("

I will review it.

", presentations.last().body) + } + @Test fun `track semantics retain artist album duration favorite and ordering`() { val resource = resource( diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index ff7d9ec8..5e37ff60 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -178,6 +178,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetSemantics.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailActions.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailMarkup.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkspace.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecipePresentations.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecipeScaling.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt", @@ -197,7 +198,7 @@ "ui/src/desktopMain/resources/marketing/raw-render-fixture.png", "ui/src/desktopMain/resources/marketing/raw-render-fixture.svg" ], - "captureSourceSha256": "95415ff1e63f81bc2e56f2613d8a431876dfeac57f8bd2be0ba0a98b30343986", + "captureSourceSha256": "5ffd3531103865096887e0e2dfedeb1e45de7fe47ec5af9be85cbb1f9f159baf", "avatarSha256": "a20433eeda834a418f92d76853633b4fc9115ad3006c5622ce2611432dc1f14d", "captures": [ { @@ -284,6 +285,81 @@ "viewport": "phone-portrait", "sha256": "f1290c89723184800c127401de7fea4256294049828d014beed5ce11c8e35ea5" }, + { + "scenario": "mail-workspace-desktop", + "file": "mail-workspace-desktop.png", + "width": 1440, + "height": 900, + "density": 1.0, + "feature": "Mail", + "surface": "Adaptive mailbox workspace", + "state": "Inbox message selected", + "purpose": "showcase", + "platform": "desktop", + "viewport": "wide", + "issue": 54, + "sha256": "dc3dbd2bc09b34bbfbcad8fcdd74b1f2555527b222f570b3d8ffcba06fc5166d" + }, + { + "scenario": "mail-workspace-mobile", + "file": "mail-workspace-mobile.png", + "width": 1080, + "height": 1800, + "density": 2.625, + "feature": "Mail", + "surface": "Adaptive mailbox workspace", + "state": "Inbox message list", + "purpose": "showcase", + "platform": "mobile", + "viewport": "phone-portrait", + "issue": 54, + "sha256": "54f1cdf144ee63a74395fe738a0e978ee8fec41ba8d0c2d6bac93c719dbec328" + }, + { + "scenario": "mail-workspace-loading-mobile", + "file": "mail-workspace-loading-mobile.png", + "width": 1080, + "height": 1800, + "density": 2.625, + "feature": "Mail", + "surface": "Adaptive mailbox workspace", + "state": "Loading inbox", + "purpose": "state-coverage", + "platform": "mobile", + "viewport": "phone-portrait", + "issue": 54, + "sha256": "11d4b57cd7fd5a38d1d13da8cbe3df349c6280b845fa8c766d84c75b7b9b14f9" + }, + { + "scenario": "mail-workspace-empty-mobile", + "file": "mail-workspace-empty-mobile.png", + "width": 1080, + "height": 1800, + "density": 2.625, + "feature": "Mail", + "surface": "Adaptive mailbox workspace", + "state": "Empty inbox", + "purpose": "state-coverage", + "platform": "mobile", + "viewport": "phone-portrait", + "issue": 54, + "sha256": "1c9b357d9f4265b886ba4d30534d708641f10278da91b15e61eda6d299a21da3" + }, + { + "scenario": "mail-workspace-error-desktop", + "file": "mail-workspace-error-desktop.png", + "width": 1440, + "height": 900, + "density": 1.0, + "feature": "Mail", + "surface": "Adaptive mailbox workspace", + "state": "Message body error", + "purpose": "state-coverage", + "platform": "desktop", + "viewport": "wide", + "issue": 54, + "sha256": "8dd96ff3e9d48f98c99d9c8b7ffddff071537c891192af6e24fb9c3243eae874" + }, { "scenario": "photo-timeline-revalidation-error-mobile", "file": "photo-timeline-revalidation-error-mobile.png", diff --git a/website/public/screenshots/mail-workspace-desktop.png b/website/public/screenshots/mail-workspace-desktop.png new file mode 100644 index 00000000..065a9025 Binary files /dev/null and b/website/public/screenshots/mail-workspace-desktop.png differ diff --git a/website/public/screenshots/mail-workspace-empty-mobile.png b/website/public/screenshots/mail-workspace-empty-mobile.png new file mode 100644 index 00000000..4ba2617f Binary files /dev/null and b/website/public/screenshots/mail-workspace-empty-mobile.png differ diff --git a/website/public/screenshots/mail-workspace-error-desktop.png b/website/public/screenshots/mail-workspace-error-desktop.png new file mode 100644 index 00000000..32b138f6 Binary files /dev/null and b/website/public/screenshots/mail-workspace-error-desktop.png differ diff --git a/website/public/screenshots/mail-workspace-loading-mobile.png b/website/public/screenshots/mail-workspace-loading-mobile.png new file mode 100644 index 00000000..30dbc97b Binary files /dev/null and b/website/public/screenshots/mail-workspace-loading-mobile.png differ diff --git a/website/public/screenshots/mail-workspace-mobile.png b/website/public/screenshots/mail-workspace-mobile.png new file mode 100644 index 00000000..d6d4577e Binary files /dev/null and b/website/public/screenshots/mail-workspace-mobile.png differ