diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt index 3bdd18d1..9a4f2fae 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt @@ -74,6 +74,51 @@ object MailLogic { /** Whether a mail still belongs to the list it is in after [action] (archive, delete, junk, move take it away). */ fun leavesList(action: String): Boolean = action in setOf(SWIPE_ARCHIVE, SWIPE_DELETE, "junk", "move", "tab") + // ─── the menu: an account's folders ───────────────────────────── + + /** + * Whether the menu shows [accountId]'s folders: as the person folded it this session ([folds]), else open for the + * account of the [box] shown. + */ + fun foldOpen(folds: Map, accountId: Long, box: Box): Boolean = folds[accountId] ?: (box.accountId == accountId) + + /** [folds] after a tap on the account (its folders open or close; the menu stays). */ + fun toggleFold(folds: Map, accountId: Long, box: Box): Map = + folds + (accountId to !foldOpen(folds, accountId, box)) + + // ─── a conversation ───────────────────────────────────────────── + + /** + * A conversation (`GET threads/`) around the open mail [currentId]: the mails before it and those after it, + * each oldest first (the newest at the bottom, as in Outlook). Without the open mail in it, all count as before. + */ + fun conversation(items: List, currentId: Long): Pair, List> { + val sorted = items.distinctBy { it.id }.sortedWith(compareBy({ it.summary.date }, { it.id })) + val at = sorted.indexOfFirst { it.id == currentId } + if (at < 0) return sorted to emptyList() + return sorted.subList(0, at) to sorted.subList(at + 1, sorted.size) + } + + /** The senders of a conversation for its line ("Anna, Tim"), each once, at most [max]. */ + fun participants(items: List, max: Int = 3): List = + items.map { it.from }.distinctBy { it.email.lowercase() }.map { it.label }.filter { it.isNotBlank() }.take(max) + + // ─── a sender ─────────────────────────────────────────────────── + + /** `GET search` for the mails of [email] in the account [accountId] (all accounts without one). */ + fun senderQuery(email: String, accountId: Long?, cursor: String? = null): Map = buildMap { + put("from", email.trim().lowercase()) + put("box", accountId?.toString() ?: BOX_ALL) + cursor?.let { put("cursor", it) } + } + + /** The server's `from` matches a part of the address: only the mails of exactly [email] stay. */ + fun fromSender(items: List, email: String): List = items.filter { it.from.email.trim().equals(email.trim(), ignoreCase = true) } + + /** The name of [email] in the person's contacts (`contacts?q=`, a `person` hit), null when there is none. */ + fun contactOf(contacts: List, email: String): MContact? = + contacts.firstOrNull { it.source == MContact.SOURCE_PERSON && it.email.trim().equals(email.trim(), ignoreCase = true) && !it.name.isNullOrBlank() } + // ─── swipes ───────────────────────────────────────────────────── const val SWIPE_ARCHIVE = MSettings.SWIPE_ARCHIVE @@ -311,21 +356,50 @@ object MailLogic { return parseRecipients(addr).addresses.firstOrNull() } + /** The page's margin (CSS px) around the mail. */ + const val PAGE_PADDING = 12 + + /** Below this a mail is not made any smaller to fit (pinching still zooms in). */ + const val MIN_ZOOM = 0.25 + + /** How often a page is laid out again to fit (each time only smaller, so it always ends). */ + const val FIT_ROUNDS = 3 + /** * The page the WebView shows: the mail's (already cleaned) HTML with a strict content policy (no scripts, no - * frames, pictures only from the app's own address) and a width that fits the phone. + * frames, pictures only from the app's own address) and a width that fits the phone: pictures, tables, long + * links and preformatted text stay inside the screen. What is still wider (a newsletter's table of a fixed 600 px) + * is made smaller as a whole with [zoom] (CSS `zoom` on the mail, as mail apps do), measured from outside by + * [fitZoom] — the page itself never runs a script. Pinching still zooms in. */ - fun page(html: String, dark: Boolean = false): String { + fun page(html: String, dark: Boolean = false, zoom: Double = 1.0): String { val csp = "default-src 'none'; img-src $BASE_URL data:; style-src 'unsafe-inline'; font-src data:; " + "script-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'" val colors = if (dark) "color-scheme: light;" else "" + val z = zoom.coerceIn(MIN_ZOOM, 1.0) + val fit = if (z < 1.0) "#gc-fit{zoom:${String.format(java.util.Locale.ROOT, "%.3f", z)}}" else "" return "" + "" + "" + - "" + - html + "" + "img{max-width:100%;height:auto}table{max-width:100%}td,th{overflow-wrap:anywhere}" + + "a{overflow-wrap:anywhere;word-break:break-word}pre{white-space:pre-wrap;overflow-wrap:anywhere}$fit" + + "
" + html + "
" + } + + /** + * The [page]'s zoom so the mail fits without scrolling sideways, from what the WebView measured: [viewCss] its + * width and [contentCss] the width of what it shows (both in CSS px, the content at the [current] zoom). Null + * when it fits already (1 px of rounding allowed) or would not get smaller; never below [MIN_ZOOM]. + */ + fun fitZoom(viewCss: Double, contentCss: Double, current: Double = 1.0): Double? { + if (viewCss <= 2 * PAGE_PADDING || contentCss <= viewCss + 1.0 || current <= MIN_ZOOM) return null + // the mail at zoom 1 (the margin on the left is not zoomed; one on the right may count or not) + val mail = (contentCss - PAGE_PADDING) / current + val zoom = ((viewCss - 2 * PAGE_PADDING) / mail).coerceIn(MIN_ZOOM, 1.0) + val rounded = kotlin.math.floor(zoom * 1000) / 1000 + return rounded.takeIf { it < current - 0.001 } } // ─── the body of a draft ──────────────────────────────────────── diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt index 38b78037..29980096 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt @@ -320,7 +320,13 @@ data class MOutboxItem( val error: String?, ) -data class MContact(val name: String?, val email: String, val source: String) +data class MContact(val name: String?, val email: String, val source: String) { + companion object { + /** A contact of the person (the coming contacts plugin); `recent`: someone written with lately. */ + const val SOURCE_PERSON = "person" + const val SOURCE_RECENT = "recent" + } +} /** "Zum Ablegen": an attachment the AI was not sure about. */ data class MFilingItem( diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt index 35a03de3..3276ee81 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -63,6 +64,15 @@ class MailRepository @Inject constructor( private val _undo = MutableStateFlow(null) val undo: StateFlow = _undo.asStateFlow() + /** The menu's folded accounts (account id → folders shown), kept while the app runs. */ + private val _folds = MutableStateFlow>(emptyMap()) + val folds: StateFlow> = _folds.asStateFlow() + + /** A tap on an account in the menu: its folders open or close ([MailLogic.toggleFold]). */ + fun toggleFold(accountId: Long, box: MailLogic.Box) { + _folds.update { MailLogic.toggleFold(it, accountId, box) } + } + @Volatile private var meAt = 0L private var refreshJob: Job? = null diff --git a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt index 7b28104b..9ef61f6b 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt @@ -16,6 +16,8 @@ import com.gatecontrol.android.ui.mail.MailOutboxScreen import com.gatecontrol.android.ui.mail.MailReadNav import com.gatecontrol.android.ui.mail.MailReadScreen import com.gatecontrol.android.ui.mail.MailSearchScreen +import com.gatecontrol.android.ui.mail.MailSenderNav +import com.gatecontrol.android.ui.mail.MailSenderScreen import com.gatecontrol.android.ui.mail.MailSettingsScreen /** Mail's ways between its screens (the tab, a page beside Start and the routes on top share them). */ @@ -71,6 +73,18 @@ internal fun NavGraphBuilder.mailRoutes( compose = { mode, reply -> if (id != null) go(Screen.MailCompose.create(ref = id, mode = mode, reply = reply)) }, mailTo = { to -> go(Screen.MailCompose.create(to = to)) }, open = { other -> navController.navigate(Screen.MailMessage.create(other)) }, + sender = { email, name, account -> go(Screen.MailSender.create(email, name, account)) }, + ), + ) + } + } + composable(Screen.MailSender.route, arguments = listOf(optional(Screen.MailSender.ARG_EMAIL), optional(Screen.MailSender.ARG_NAME), optional(Screen.MailSender.ARG_ACCOUNT))) { + GcContentWidth { + MailSenderScreen( + MailSenderNav( + back = back, + write = { to, account -> go(Screen.MailCompose.create(account = account, to = to)) }, + open = { id -> navController.navigate(Screen.MailMessage.create(id)) }, ), ) } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt index 17ffac74..ce817ac6 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -160,6 +160,17 @@ sealed class Screen(val route: String) { } } data object MailSearch : Screen("mail/search") + /** A sender's page: their mails in the account `account`. */ + data object MailSender : Screen("mail/sender?email={email}&name={name}&account={account}") { + const val ARG_EMAIL = "email" + const val ARG_NAME = "name" + const val ARG_ACCOUNT = "account" + fun create(email: String, name: String? = null, account: Long? = null): String { + fun enc(v: String) = java.net.URLEncoder.encode(v, "UTF-8").replace("+", "%20") + val args = listOfNotNull("email=" + enc(email), name?.takeIf { it.isNotBlank() }?.let { "name=" + enc(it.take(200)) }, account?.let { "account=$it" }) + return "mail/sender?" + args.joinToString("&") + } + } /** "Zum Ablegen": attachments the AI was not sure about. */ data object MailFiling : Screen("mail/filing") /** "Konto hinzufügen"; `account` = sign in to that Microsoft account again. */ diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt index 65252464..20f5e0b4 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt @@ -235,9 +235,9 @@ internal fun FileBadge(name: String, mime: String, size: Int = 40) { /** The round avatar with the sender's initials in the account's color. */ @Composable -internal fun SenderAvatar(name: String?, email: String, color: Color) { - Box(Modifier.size(40.dp).clip(CircleShape).background(color), contentAlignment = Alignment.Center) { - Text(MailLogic.initials(name, email), color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = 14.sp) +internal fun SenderAvatar(name: String?, email: String, color: Color, size: Dp = 40.dp) { + Box(Modifier.size(size).clip(CircleShape).background(color), contentAlignment = Alignment.Center) { + Text(MailLogic.initials(name, email), color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = (size.value * 0.35f).sp) } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt index 94c5e5ec..0740eb47 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading 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 @@ -114,6 +115,7 @@ fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) val undo by viewModel.undo.collectAsStateWithLifecycle() val message by viewModel.message.collectAsStateWithLifecycle() val reopen by viewModel.reopen.collectAsStateWithLifecycle() + val folds by viewModel.folds.collectAsStateWithLifecycle() val context = mailContext() val scope = rememberCoroutineScope() val drawer = rememberDrawerState(DrawerValue.Closed) @@ -165,6 +167,8 @@ fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) box = box, filing = filing, outbox = outbox, + folds = folds, + onFold = viewModel::toggleFold, onBox = { close(); viewModel.selectBox(it) }, onFiling = { close(); nav.filing() }, onOutbox = { close(); nav.outbox() }, @@ -573,8 +577,9 @@ internal object MailFolders { /** * The menu (mockup "Seitenmenü"): "Mail", "Alle Posteingänge", "Zum Ablegen" (with Drive), "Postausgang" (when - * something waits), the accounts — the chosen one with its folders — and below "Konto hinzufügen" and the settings. - * Calendar and resources come later (phase 2/3). + * something waits), the accounts with their folders and below "Konto hinzufügen" and the settings. A tap on an + * account opens or closes its folders (the menu stays, [folds] keeps it for the session); picking its "Posteingang" + * or a folder closes the menu. Calendar and resources come later (phase 2/3). */ @Composable private fun MailDrawer( @@ -583,6 +588,8 @@ private fun MailDrawer( box: MailLogic.Box, filing: Int, outbox: Int, + folds: Map, + onFold: (Long) -> Unit, onBox: (MailLogic.Box) -> Unit, onFiling: () -> Unit, onOutbox: () -> Unit, @@ -611,31 +618,26 @@ private fun MailDrawer( item(key = "accounts") { DrawerLabel(stringResource(R.string.mail_accounts)) } } accounts.forEach { a -> + val open = MailLogic.foldOpen(folds, a.id, box) item(key = "a${a.id}") { - DrawerAccount(a, me?.unreadByAccount?.get(a.id) ?: 0, on = box.accountId == a.id && box.folderId == null) { - onBox(MailLogic.Box(a.id)) - } + DrawerAccount(a, me?.unreadByAccount?.get(a.id) ?: 0, on = !open && box.accountId == a.id, open = open) { onFold(a.id) } } - if (box.accountId == a.id) { + if (open) { val list = MailFolders.ordered(folders[a.id].orEmpty()) + // the account's inbox is always there (also before its folders came) + if (list.none { it.role == MFolder.ROLE_INBOX }) { + item(key = "i${a.id}") { + DrawerFolder(stringResource(R.string.mail_inbox), 0, on = box.accountId == a.id && box.folderId == null) { onBox(MailLogic.Box(a.id)) } + } + } list.forEach { f -> item(key = "f${f.id}") { - val label = MailFolders.label(f) - Row( - Modifier - .fillMaxWidth() - .heightIn(min = 40.dp) - .clip(RoundedCornerShape(12.dp)) - .background(if (box.folderId == f.id) extra.accentBg else Color.Transparent) - .selectable(selected = box.folderId == f.id, role = Role.Tab) { - onBox(MailLogic.Box(a.id, f.id.takeIf { f.role != MFolder.ROLE_INBOX })) - } - .padding(start = 58.dp, end = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, style = MaterialTheme.typography.bodyMedium, color = if (box.folderId == f.id) extra.accentText else MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - if (f.unread > 0) Text(f.unread.toString(), style = MaterialTheme.typography.labelMedium, color = extra.muted, fontWeight = FontWeight.Bold) - } + val inbox = f.role == MFolder.ROLE_INBOX + DrawerFolder( + MailFolders.label(f), + f.unread, + on = if (inbox) box.accountId == a.id && box.folderId == null else box.folderId == f.id, + ) { onBox(MailLogic.Box(a.id, f.id.takeIf { !inbox })) } } } } @@ -692,24 +694,49 @@ private fun DrawerItem(icon: androidx.compose.ui.graphics.vector.ImageVector?, l } } -/** An account in the menu (like the Outlook app): its picture or the provider's mark, the name, the address below. */ +/** A folder of an account in the menu (indented below it). */ @Composable -private fun DrawerAccount(a: MAccount, count: Int, on: Boolean, onClick: () -> Unit) { +private fun DrawerFolder(label: String, unread: Int, on: Boolean, onClick: () -> Unit) { val extra = GateControlTheme.extraColors Row( Modifier .fillMaxWidth() - .heightIn(min = 56.dp) + .heightIn(min = 40.dp) .clip(RoundedCornerShape(12.dp)) .background(if (on) extra.accentBg else Color.Transparent) .selectable(selected = on, role = Role.Tab, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 6.dp), + .padding(start = 58.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = if (on) extra.accentText else MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + if (unread > 0) Text(unread.toString(), style = MaterialTheme.typography.labelMedium, color = extra.muted, fontWeight = FontWeight.Bold) + } +} + +/** + * An account in the menu (like the Outlook app): its picture or the provider's mark, the name, the address below and + * the arrow; a tap opens or closes its folders. + */ +@Composable +private fun DrawerAccount(a: MAccount, count: Int, on: Boolean, open: Boolean, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + val state = stringResource(if (open) R.string.mail_folders_shown else R.string.mail_folders_hidden) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (on) extra.accentBg else Color.Transparent) + .clickable(role = Role.Button, onClickLabel = stringResource(if (open) R.string.mail_folders_hide else R.string.mail_folders_show), onClick = onClick) + .semantics { stateDescription = state } + .padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { MailAccountAvatar(a, size = 34.dp, ring = if (on) extra.accentBg else MaterialTheme.colorScheme.surface) MailAccountText(a, Modifier.weight(1f), color = if (on) extra.accentText else MaterialTheme.colorScheme.onSurface, bold = on) if (count > 0) Text(count.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = extra.muted) + Icon(if (open) GcIcons.ChevronUp else GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeViewModel.kt index 504fef92..8421f9df 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeViewModel.kt @@ -53,6 +53,8 @@ class MailHomeViewModel @Inject constructor( val meError: StateFlow = repository.meError val folders: StateFlow>> = repository.folders val undo: StateFlow = repository.undo + /** The menu's folded accounts ([MailLogic.foldOpen]). */ + val folds: StateFlow> = repository.folds private val _box = MutableStateFlow(MailLogic.Box(saved.get(KEY_ACCOUNT)?.takeIf { it > 0 }, saved.get(KEY_FOLDER)?.takeIf { it > 0 })) val box: StateFlow = _box.asStateFlow() @@ -107,6 +109,9 @@ class MailHomeViewModel @Inject constructor( } } + /** A tap on an account in the menu: its folders open or close, the menu stays. */ + fun toggleFold(accountId: Long) = repository.toggleFold(accountId, _box.value) + fun selectBox(box: MailLogic.Box) { if (box == _box.value) return _box.value = box diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt index 5206b200..ca67e5b4 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt @@ -1,6 +1,7 @@ package com.gatecontrol.android.ui.mail import android.annotation.SuppressLint +import android.content.Context import android.graphics.Color import android.view.ViewGroup import android.webkit.WebResourceRequest @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier @@ -28,7 +30,11 @@ import java.io.ByteArrayInputStream * file or content access, no forms, storage or windows; the page's requests are answered by the app — the plugin's * pictures (`img/`, `cid//`) loaded with the app's own sign-in through [loader], everything * else answered empty, so the mail never reaches the network by itself. A tap on a link leaves the app ([onLink]). - * Shown on a light sheet: mails are made for one. + * Shown on a light sheet: mails are made for one (the app has no dark rendering of mails). + * + * Wide mails (a newsletter's table of a fixed width) fit the screen: the page keeps pictures and tables inside it, + * and what is still wider is measured from outside (the WebView's content width, no script) and laid out again + * smaller ([MailLogic.fitZoom], at most [MailLogic.FIT_ROUNDS] times). Pinching still zooms in. */ @SuppressLint("SetJavaScriptEnabled") @Composable @@ -42,12 +48,15 @@ internal fun MailHtmlView( ) { val load = rememberUpdatedState(loader) val link = rememberUpdatedState(onLink) - val page = remember(html) { MailLogic.page(html) } + // the zoom that makes the mail fit (1 until measured), and how often it was laid out again + val zoom = remember(html) { mutableDoubleStateOf(1.0) } + val rounds = remember(html) { intArrayOf(0) } + val page = remember(html, zoom.doubleValue) { MailLogic.page(html, zoom = zoom.doubleValue) } Box(modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(androidx.compose.ui.graphics.Color.White)) { AndroidView( modifier = Modifier.fillMaxWidth(), factory = { ctx -> - WebView(ctx).apply { + MailWebView(ctx).apply { layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) setBackgroundColor(Color.WHITE) isVerticalScrollBarEnabled = false @@ -81,9 +90,18 @@ internal fun MailHtmlView( val path = MailLogic.interceptPath(url) if (path != null && request.method.equals("GET", ignoreCase = true)) { val got = runCatching { runBlocking { load.value(path) } }.getOrNull() - if (got != null) return WebResourceResponse(got.second, null, ByteArrayInputStream(got.first)) + // never kept by the WebView: a picture refused while blocked comes once "Bilder laden" allowed it + if (got != null) return WebResourceResponse(got.second, null, 200, "OK", NO_STORE, ByteArrayInputStream(got.first)) } - return WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) + return WebResourceResponse("text/plain", "utf-8", 404, "Not Found", NO_STORE, ByteArrayInputStream(ByteArray(0))) + } + + override fun onPageFinished(view: WebView, url: String?) { + (view as? MailWebView)?.measureSoon() + } + + override fun onScaleChanged(view: WebView, oldScale: Float, newScale: Float) { + (view as? MailWebView)?.scaleNow = newScale } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { @@ -96,6 +114,13 @@ internal fun MailHtmlView( }, onRelease = { web -> web.destroy() }, update = { web -> + web.onWide = { viewCss, contentCss -> + val next = MailLogic.fitZoom(viewCss, contentCss, zoom.doubleValue) + if (next != null && rounds[0] < MailLogic.FIT_ROUNDS) { + rounds[0]++ + zoom.doubleValue = next + } + } val key = page to round if (web.tag != key) { web.tag = key @@ -105,3 +130,39 @@ internal fun MailHtmlView( ) } } + +/** Answers of the page's requests are never kept by the WebView. */ +private val NO_STORE = mapOf("Cache-Control" to "no-store") + +/** + * The mail's WebView, which can tell how wide its content is ([onWide], in CSS px): after the page loaded and whenever + * its size changes (pictures came). + */ +private class MailWebView(context: Context) : WebView(context) { + /** The page's scale now (the WebView's pixels per CSS px times the pinch). */ + var scaleNow: Float = context.resources.displayMetrics.density + var onWide: ((viewCss: Double, contentCss: Double) -> Unit)? = null + private val measure = Runnable { measureNow() } + + fun measureSoon() { + removeCallbacks(measure) + postDelayed(measure, MEASURE_DELAY_MS) + } + + private fun measureNow() { + // the page is laid out device-wide (viewport width=device-width); the content's width at the scale now (pinched + // or not) in CSS px + val density = resources.displayMetrics.density + if (width <= 0 || scaleNow <= 0f) return + onWide?.invoke(width / density.toDouble(), computeHorizontalScrollRange() / scaleNow.toDouble()) + } + + override fun onSizeChanged(w: Int, h: Int, ow: Int, oh: Int) { + super.onSizeChanged(w, h, ow, oh) + measureSoon() + } + + private companion object { + const val MEASURE_DELAY_MS = 150L + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt index 26838ee3..96f0c037 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.heading 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 @@ -72,12 +73,15 @@ data class MailReadNav( val mailTo: (String) -> Unit, /** Another mail of the conversation. */ val open: (Long) -> Unit, + /** The sender's page (their mails in the account [accountId]). */ + val sender: (email: String, name: String?, accountId: Long) -> Unit = { _, _, _ -> }, ) /** - * A mail (mockup "Mail lesen mit KI und Anhang nach Drive"): archive, delete and more on top; subject, sender and - * account; the AI bar ("Kurz gesagt", to-dos as chips); the attachments with Drive's suggestion; "Bilder laden"; - * the body; the earlier mails of the conversation; answer suggestions, "Antworten" and "Weiterleiten" below. + * A mail (mockup "Mail lesen mit KI und Anhang nach Drive"): archive, delete and more on top; subject, the + * conversation ("Unterhaltung · n Mails": a tap shows all its mails, oldest first, each unfolding), sender (a tap + * opens the sender's page) and account; the AI bar ("Kurz gesagt", to-dos as chips); the attachments with Drive's + * suggestion; "Bilder laden"; the body; answer suggestions, "Antworten" and "Weiterleiten" below. */ @Composable fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewModel()) { @@ -152,11 +156,35 @@ fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewMode Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Header(m, account?.label, accountColor(account)) + Subject(m) + // the conversation: its line, and once tapped the mails before this one (oldest first) + val (older, newer) = remember(state.conversation, m.id) { MailLogic.conversation(state.conversation, m.id) } + val thread = me?.settings?.threads != false && m.summary.threadId != null && m.summary.threadCount > 1 + val threadMail: @Composable (MMessage) -> Unit = { t -> + ThreadMail( + t, + color = accountColor(me?.account(t.summary.accountId)), + unfolded = t.id in state.threadUnfolded, + onFold = { viewModel.toggleThreadMail(t.id) }, + onOpen = { nav.open(t.id) }, + loader = viewModel::picture, + onLink = onLink, + ) + } + if (thread) { + ThreadLine( + count = maxOf(m.summary.threadCount, state.conversation.size), + names = MailLogic.participants(state.conversation.map { it.summary }.ifEmpty { listOf(m.summary) }), + open = state.threadOpen, + loading = state.threadLoading, + onToggle = viewModel::toggleThread, + ) + if (state.threadOpen) older.forEach { threadMail(it) } + } + SenderLine(m, account?.label, accountColor(account), onSender = { nav.sender(m.summary.from.email, m.summary.from.name, m.summary.accountId) }) if (state.offline) { GcBanner(tone = GcTone.Neutral, icon = GcIcons.Wifi) { Text(stringResource(R.string.mail_offline_copy), style = MaterialTheme.typography.bodySmall) } } - if (state.thread.isNotEmpty()) ThreadBox(state.thread, nav.open) AiCard(state.ai, me, onRetry = { viewModel.runAi(force = true) }, onFamily = viewModel::todoToFamily, onCalendar = { todo -> addToCalendar(context, todo, todo.text) }) @@ -223,6 +251,8 @@ fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewMode ) } } + // the mails after this one (the newest at the bottom) + if (thread && state.threadOpen) newer.forEach { threadMail(it) } } BottomBar( replies = (state.ai as? MailAiState.Ready)?.ai?.replies.orEmpty(), @@ -254,28 +284,46 @@ fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewMode } @Composable -private fun Header(m: MMessage, accountLabel: String?, color: androidx.compose.ui.graphics.Color) { +private fun Subject(m: MMessage) { + Text( + m.summary.subject.ifEmpty { stringResource(R.string.mail_no_subject) }, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() }, + ) +} + +/** The sender (a tap: their page), the account and the time (a tap: the details). */ +@Composable +private fun SenderLine(m: MMessage, accountLabel: String?, color: androidx.compose.ui.graphics.Color, onSender: () -> Unit) { val extra = GateControlTheme.extraColors var details by remember { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - m.summary.subject.ifEmpty { stringResource(R.string.mail_no_subject) }, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.semantics { heading() }, - ) - Row( - Modifier.clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_details)) { details = !details }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - MailDot(color) - Text(m.summary.from.label.ifEmpty { stringResource(R.string.mail_unknown_sender) }, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - Text( - listOfNotNull(accountLabel?.let { stringResource(R.string.mail_to_account, it) }, mailTime(m.summary.date)).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = extra.muted, - maxLines = 1, - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + Modifier + .weight(1f, fill = false) + .heightIn(min = 32.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_sender_show), onClick = onSender), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MailDot(color) + Text(m.summary.from.label.ifEmpty { stringResource(R.string.mail_unknown_sender) }, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Row( + Modifier.heightIn(min = 32.dp).clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_details)) { details = !details }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + listOfNotNull(accountLabel?.let { stringResource(R.string.mail_to_account, it) }, mailTime(m.summary.date)).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + maxLines = 1, + ) + Icon(if (details) GcIcons.ChevronUp else GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(14.dp)) + } } if (details) { SelectionContainer { @@ -301,25 +349,99 @@ private fun DetailLine(label: String, list: List) { ) } -/** The earlier mails of the conversation (tap: open one). */ +/** "Unterhaltung · n Mails" with its senders: a tap shows all its mails (or folds them again). */ @Composable -private fun ThreadBox(thread: List, onOpen: (Long) -> Unit) { +private fun ThreadLine(count: Int, names: List, open: Boolean, loading: Boolean, onToggle: () -> Unit) { val extra = GateControlTheme.extraColors + val state = stringResource(if (open) R.string.mail_thread_shown else R.string.mail_thread_hidden) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable(role = Role.Button, onClickLabel = stringResource(if (open) R.string.mail_thread_hide else R.string.mail_thread_show), onClick = onToggle) + .semantics { stateDescription = state } + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(pluralStringResource(R.plurals.mail_thread, count, count), style = MaterialTheme.typography.labelMedium, color = extra.muted) + if (names.isNotEmpty()) { + Text(names.joinToString(", "), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (loading) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Icon(if (open) GcIcons.ChevronUp else GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) + } +} + +/** + * Another mail of the conversation: sender, time and preview; a tap unfolds it (its body, "Mail öffnen" for the + * attachments and answering). + */ +@Composable +private fun ThreadMail( + t: MMessage, + color: androidx.compose.ui.graphics.Color, + unfolded: Boolean, + onFold: () -> Unit, + onOpen: () -> Unit, + loader: suspend (String) -> Pair?, + onLink: (String) -> Unit, +) { + val extra = GateControlTheme.extraColors + val s = t.summary + val state = stringResource(if (unfolded) R.string.mail_thread_shown else R.string.mail_thread_hidden) Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(MaterialTheme.colorScheme.surface)) { - Text( - pluralStringResource(R.plurals.mail_thread, thread.size + 1, thread.size + 1), - style = MaterialTheme.typography.labelMedium, - color = extra.muted, - modifier = Modifier.padding(start = 14.dp, top = 10.dp, bottom = 4.dp), - ) - thread.forEach { t -> - Row( - Modifier.fillMaxWidth().heightIn(min = 44.dp).clickable { onOpen(t.id) }.padding(horizontal = 14.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(t.from.label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (t.unread) FontWeight.Bold else FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - Text(mailTime(t.date), style = MaterialTheme.typography.bodySmall, color = extra.muted) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .clickable(role = Role.Button, onClick = onFold) + .semantics { stateDescription = state } + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SenderAvatar(s.from.name, s.from.email, color, size = 32.dp) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + s.from.label.ifEmpty { stringResource(R.string.mail_unknown_sender) }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (s.unread) FontWeight.Bold else FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text(mailTime(s.date), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + if (!unfolded) { + val preview = s.snippet.ifBlank { s.subject } + if (preview.isNotBlank()) Text(preview, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + if (unfolded) { + Column(Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + val html = t.html + if (html != null) { + val page = remember(html, t.attachments) { MailLogic.rewriteCid(html, t.id, t.attachments) } + MailHtmlView(page, loader = loader, onLink = onLink) + } else { + SelectionContainer { + Text(t.text.orEmpty(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), modifier = Modifier.padding(horizontal = 6.dp)) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (t.imagesBlocked || t.attachments.any { !it.inline }) { + Icon(if (t.imagesBlocked) GcIcons.Photo else GcIcons.Paperclip, contentDescription = null, tint = extra.muted, modifier = Modifier.padding(start = 6.dp).size(14.dp)) + } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onOpen) { Text(stringResource(R.string.mail_thread_open)) } + } } } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt index 48484112..6bf7c961 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt @@ -61,8 +61,13 @@ data class MailReadState( val loading: Boolean = true, val offline: Boolean = false, val error: String? = null, - /** The other mails of the conversation, oldest first. */ - val thread: List = emptyList(), + /** The mails of the conversation (`GET threads/`, this one among them), oldest first; empty until loaded. */ + val conversation: List = emptyList(), + /** "Unterhaltung · n Mails" was tapped: all its mails show, the open one unfolded. */ + val threadOpen: Boolean = false, + val threadLoading: Boolean = false, + /** The other mails of the conversation unfolded. */ + val threadUnfolded: Set = emptySet(), val attachments: List = emptyList(), val ai: MailAiState = MailAiState.Off, val imagesBusy: Boolean = false, @@ -200,10 +205,7 @@ class MailReadViewModel @Inject constructor( val me = repository.me.value ?: repository.refresh().valueOrNull() val account = me?.account(m.summary.accountId) // the conversation (when the person groups by conversation) - val threadId = m.summary.threadId - if (me?.settings?.threads != false && threadId != null && m.summary.threadCount > 1) { - api.thread(threadId).valueOrNull()?.let { items -> _state.update { it.copy(thread = items.map { t -> t.summary }.filter { t -> t.id != m.id }) } } - } + if (me?.settings?.threads != false && hasThread(m)) loadThread() // the AI bar: only where the server offers AI and the account allows it if (me?.has(MMe.FEATURE_AI) == true && account?.ai != false) { if (_state.value.ai !is MailAiState.Ready) _state.update { it.copy(ai = MailAiState.Loading) } @@ -222,6 +224,35 @@ class MailReadViewModel @Inject constructor( } } + private fun hasThread(m: MMessage): Boolean = m.summary.threadId != null && m.summary.threadCount > 1 + + /** The conversation's mails (`GET threads/`, oldest first). */ + private suspend fun loadThread() { + val threadId = _state.value.message?.summary?.threadId ?: return + if (_state.value.threadLoading) return + _state.update { it.copy(threadLoading = true) } + val r = api.thread(threadId) + _state.update { s -> + when (r) { + is MResult.Ok -> s.copy(conversation = r.value, threadLoading = false) + else -> s.copy(threadLoading = false) + } + } + if (r !is MResult.Ok && _state.value.threadOpen) _event.value = MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(r))) + } + + /** A tap on "Unterhaltung · n Mails": all mails of the conversation show (loaded now when not yet), or fold again. */ + fun toggleThread() { + val open = !_state.value.threadOpen + _state.update { it.copy(threadOpen = open) } + if (open && _state.value.conversation.isEmpty()) viewModelScope.launch { loadThread() } + } + + /** Another mail of the conversation unfolded or folded. */ + fun toggleThreadMail(id: Long) { + _state.update { s -> s.copy(threadUnfolded = if (id in s.threadUnfolded) s.threadUnfolded - id else s.threadUnfolded + id) } + } + private fun aiError(r: MResult<*>): MailAiState = when ((r as? MResult.Failed)?.code) { "ai_off" -> MailAiState.Off else -> MailAiState.Error(MailHomeViewModel.code(r)) @@ -258,6 +289,8 @@ class MailReadViewModel @Inject constructor( is MResult.Ok -> { show(r.value.first, loading = false) cache.putMessage(r.value.second) + // the HTML is the same as before (its pictures' addresses do not change): the page loads them now + imagesAllowed(sender) } else -> _event.value = MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(r))) } @@ -449,6 +482,16 @@ class MailReadViewModel @Inject constructor( } } + /** + * The pictures were allowed (`POST messages//images`): those the page was refused are asked for again — none of + * them is kept (a refusal never is, and the WebView keeps no answer); always for the [sender]: the conversation's + * mails come again with theirs. + */ + private fun imagesAllowed(sender: Boolean) { + reloadPictures() + if (sender && _state.value.conversation.isNotEmpty()) viewModelScope.launch { loadThread() } + } + /** The page asks for its pictures again (those it has come from [pictures]). */ private fun reloadPictures(fromTimer: Boolean = false) { if (!fromTimer) synchronized(this) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt new file mode 100644 index 00000000..13d0e1d5 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt @@ -0,0 +1,136 @@ +package com.gatecontrol.android.ui.mail + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MContact +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.theme.GateControlTheme + +/** Where the sender's page leads. */ +data class MailSenderNav( + val back: () -> Unit, + /** A new mail to [to] from the account [accountId]. */ + val write: (to: String, accountId: Long?) -> Unit, + val open: (Long) -> Unit, +) + +/** + * A sender (a tap on the sender of a mail): avatar or initials, name and address, "Mail schreiben" and "Adresse + * kopieren"; below all their mails in the account, page by page. + */ +@Composable +fun MailSenderScreen(nav: MailSenderNav, viewModel: MailSenderViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + val account = me?.account(state.accountId) + Column(Modifier.fillMaxSize()) { + GcSubpageBar(title = stringResource(R.string.mail_sender_title), onBack = nav.back, backDescription = stringResource(R.string.mail_back)) + LazyColumn(Modifier.weight(1f)) { + item(key = "head") { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + SenderAvatar(state.label.takeIf { it != state.email }, state.email, accountColor(account), size = 72.dp) + Text( + state.label, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp).semantics { heading() }, + ) + if (state.label != state.email) { + SelectionContainer { Text(state.email, style = MaterialTheme.typography.bodyMedium, color = extra.muted, textAlign = TextAlign.Center) } + } + SenderContact(state.contact) + Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcPrimaryButton( + stringResource(R.string.mail_sender_write), + onClick = { nav.write(state.email, state.accountId) }, + modifier = Modifier.weight(1f), + icon = MailIcons.Pen, + minHeight = 44.dp, + ) + GcOutlineButton( + stringResource(R.string.mail_sender_copy), + onClick = { + copy(context, state.email) + toast(context, context.getString(R.string.mail_sender_copied)) + }, + modifier = Modifier.weight(1f), + icon = GcIcons.Copy, + minHeight = 44.dp, + ) + } + } + } + item(key = "label") { + Text( + if (account != null) stringResource(R.string.mail_sender_mails_in, account.label) else stringResource(R.string.mail_sender_mails), + style = MaterialTheme.typography.labelMedium, + color = extra.muted, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp).semantics { heading() }, + ) + } + items(state.items, key = { it.id }) { item -> + MailRow(item = item, account = me?.account(item.accountId), onClick = { nav.open(item.id) }, onLongClick = { nav.open(item.id) }) + MailDivider() + } + when { + state.loading -> item(key = "busy") { + Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } + } + state.error != null && state.items.isEmpty() -> item(key = "error") { ErrorBox(mailText(context, state.error), onRetry = viewModel::load) } + state.items.isEmpty() -> item(key = "none") { ErrorBox(stringResource(R.string.mail_sender_none), onRetry = null) } + state.next != null -> item(key = "more") { TextButton(onClick = viewModel::more, modifier = Modifier.padding(16.dp)) { Text(stringResource(R.string.mail_more_results)) } } + } + } + } +} + +/** + * What the person's contacts know about the sender. For now only that they are one (the name above comes from + * there); the coming contacts plugin fills this place (phone, company, …) — no contacts are kept by Mail itself. + */ +@Composable +private fun SenderContact(contact: MContact?) { + if (contact == null) return + Text(stringResource(R.string.mail_sender_in_contacts), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.accentText) +} + +private fun copy(context: Context, text: String) { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + runCatching { cm.setPrimaryClip(ClipData.newPlainText("Mail", text)) } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt new file mode 100644 index 00000000..19d8c025 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt @@ -0,0 +1,98 @@ +package com.gatecontrol.android.ui.mail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MContact +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSummary +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class MailSenderState( + val email: String = "", + /** The name the mail gave. */ + val name: String? = null, + val accountId: Long? = null, + /** + * The sender in the person's contacts (`contacts?q=`, a `person` hit), null without one. The place for what the + * coming contacts plugin knows about them; for now only its name is shown. + */ + val contact: MContact? = null, + /** Their mails in the account, newest first. */ + val items: List = emptyList(), + val next: String? = null, + val loading: Boolean = true, + val error: String? = null, +) { + /** The name shown: from the contacts first, else from the mail. */ + val label: String get() = contact?.name?.takeIf { it.isNotBlank() } ?: name?.takeIf { it.isNotBlank() } ?: email +} + +/** A sender's page: who it is (the contacts' name when there is one) and their mails in the account (`GET search?from=`). */ +@HiltViewModel +class MailSenderViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val api = repository.api + val me: StateFlow = repository.me + + private val _state = MutableStateFlow( + MailSenderState( + email = saved.get(Screen.MailSender.ARG_EMAIL)?.trim().orEmpty(), + name = saved.get(Screen.MailSender.ARG_NAME)?.trim()?.takeIf { it.isNotEmpty() }, + accountId = saved.get(Screen.MailSender.ARG_ACCOUNT)?.toLongOrNull()?.takeIf { it > 0 }, + ), + ) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + /** The first page of their mails and their contact. */ + fun load() { + val s = _state.value + if (!MailLogic.isEmail(s.email)) { + _state.update { it.copy(loading = false, error = "not_found") } + return + } + _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + api.contacts(s.email).valueOrNull()?.let { list -> _state.update { it.copy(contact = MailLogic.contactOf(list, s.email)) } } + } + viewModelScope.launch { + when (val r = api.search(MailLogic.senderQuery(s.email, s.accountId))) { + is MResult.Ok -> _state.update { it.copy(items = MailLogic.fromSender(r.value.items, s.email), next = r.value.next, loading = false, error = null) } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + /** The next page (the cursor of the last one). */ + fun more() { + val s = _state.value + val next = s.next ?: return + if (s.loading) return + _state.update { it.copy(loading = true) } + viewModelScope.launch { + when (val r = api.search(MailLogic.senderQuery(s.email, s.accountId, next))) { + is MResult.Ok -> _state.update { + it.copy(items = MailLogic.merge(it.items, MailLogic.fromSender(r.value.items, s.email)), next = r.value.next, loading = false) + } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } +} diff --git a/app/src/main/res/values-de/strings_mail.xml b/app/src/main/res/values-de/strings_mail.xml index 5776c1d5..ab0c63c4 100644 --- a/app/src/main/res/values-de/strings_mail.xml +++ b/app/src/main/res/values-de/strings_mail.xml @@ -238,6 +238,25 @@ Dieses Konto gibt es schon. Drive-Links sind ausgeschaltet. Das hat nicht geklappt. + + Ordner zeigen + Ordner ausblenden + Ordner aufgeklappt + Ordner zugeklappt + Alle Mails der Unterhaltung zeigen + Unterhaltung zuklappen + Aufgeklappt + Zugeklappt + Mail öffnen + Absender zeigen + Absender + Mail schreiben + Adresse kopieren + Adresse kopiert + Mails von diesem Absender + Mails von diesem Absender in %1$s + Noch keine Mails von diesem Absender. + In deinen Kontakten Unterhaltung · %d Mail Unterhaltung · %d Mails diff --git a/app/src/main/res/values/strings_mail.xml b/app/src/main/res/values/strings_mail.xml index 3f293026..1f20b20d 100644 --- a/app/src/main/res/values/strings_mail.xml +++ b/app/src/main/res/values/strings_mail.xml @@ -238,6 +238,25 @@ This account exists already. Drive links are switched off. That did not work. + + Show folders + Hide folders + Folders shown + Folders hidden + Show all mails of the conversation + Hide the conversation + Unfolded + Folded + Open mail + Show sender + Sender + Write mail + Copy address + Address copied + Mails from this sender + Mails from this sender in %1$s + No mails from this sender yet. + In your contacts Conversation · %d mail Conversation · %d mails diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailReaderUxTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailReaderUxTest.kt new file mode 100644 index 00000000..f4f512c0 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailReaderUxTest.kt @@ -0,0 +1,124 @@ +package com.gatecontrol.android.mail + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** The reader's rules: a wide mail fits, the menu's folded accounts, a conversation, a sender's mails. */ +class MailReaderUxTest { + + private fun s(id: Long, date: Long, from: String = "a@b.de", name: String? = "Anna") = MSummary( + id = id, accountId = 1, folderId = null, threadId = 9, threadCount = 3, from = MAddress(name, from), to = emptyList(), + subject = "s$id", snippet = "", date = date, unread = false, flagged = false, answered = false, hasAttachments = false, + tab = MSummary.TAB_RELEVANT, savedToDrive = false, aiSummary = null, + ) + + private fun m(id: Long, date: Long, from: String = "a@b.de", name: String? = "Anna") = + MMessage(s(id, date, from, name), emptyList(), emptyList(), emptyList(), "

$id

", null, false, 0, emptyList(), null) + + // ─── a wide mail fits ─────────────────────────────────────────── + + @Test + fun `the page keeps pictures, tables, links and pre inside and wraps the mail for the zoom`() { + val page = MailLogic.page("
x
") + assertTrue(page.contains("name=\"viewport\" content=\"width=device-width, initial-scale=1\""), "device wide, pinching allowed") + assertFalse(page.contains("user-scalable"), "pinching stays allowed") + assertTrue(page.contains("img{max-width:100%;height:auto}")) + assertTrue(page.contains("table{max-width:100%}")) + assertTrue(page.contains("pre{white-space:pre-wrap")) + assertTrue(page.contains("a{overflow-wrap:anywhere")) + assertTrue(page.contains("
")) + assertFalse(page.contains("zoom:"), "not zoomed until measured") + assertTrue(page.contains("script-src 'none'"), "the mail never runs a script") + } + + @Test + fun `a measured zoom goes into the page, never below the minimum`() { + assertTrue(MailLogic.page("

x

", zoom = 0.6).contains("#gc-fit{zoom:0.600}")) + assertTrue(MailLogic.page("

x

", zoom = 0.05).contains("#gc-fit{zoom:0.250}")) + assertFalse(MailLogic.page("

x

", zoom = 1.4).contains("zoom:")) + } + + @Test + fun `fitZoom - a 600 px newsletter on a 360 px phone is made smaller, a fitting mail is not`() { + // 360 wide, the mail's table 600 + the left margin + val z = MailLogic.fitZoom(360.0, 612.0)!! + assertEquals((360.0 - 24) / 600, z, 0.002) + // laid out at that zoom it fits: no further round + assertNull(MailLogic.fitZoom(360.0, 12 + 600 * z + 12)) + // fits already (1 px of rounding) + assertNull(MailLogic.fitZoom(360.0, 360.0)) + assertNull(MailLogic.fitZoom(360.0, 361.0)) + // measured again at a zoom: the next is computed from the mail at zoom 1 + val again = MailLogic.fitZoom(360.0, 12 + 600 * 0.8, current = 0.8)!! + assertEquals((360.0 - 24) / 600, again, 0.002) + // never below the minimum, never larger than now + assertEquals(MailLogic.MIN_ZOOM, MailLogic.fitZoom(360.0, 5000.0)!!, 0.0001) + assertNull(MailLogic.fitZoom(360.0, 5000.0, current = MailLogic.MIN_ZOOM)) + assertNull(MailLogic.fitZoom(0.0, 600.0)) + } + + // ─── the menu: folded accounts ────────────────────────────────── + + @Test + fun `an account's folders - open for the account shown until folded, then as the person left them`() { + val box = MailLogic.Box(3) + assertTrue(MailLogic.foldOpen(emptyMap(), 3, box), "the shown account starts open") + assertFalse(MailLogic.foldOpen(emptyMap(), 4, box)) + // the open one closes again (it could not before) + val closed = MailLogic.toggleFold(emptyMap(), 3, box) + assertFalse(MailLogic.foldOpen(closed, 3, box)) + val other = MailLogic.toggleFold(closed, 4, box) + assertTrue(MailLogic.foldOpen(other, 4, box)) + assertFalse(MailLogic.foldOpen(other, 3, box), "each account keeps its own") + // picking another box later keeps what the person chose + assertFalse(MailLogic.foldOpen(other, 3, MailLogic.Box(3, 11))) + assertTrue(MailLogic.foldOpen(MailLogic.toggleFold(other, 3, box), 3, box)) + } + + // ─── a conversation ───────────────────────────────────────────── + + @Test + fun `a conversation around the open mail - older above, newer below, each oldest first`() { + val items = listOf(m(3, 300), m(1, 100), m(2, 200), m(4, 400)) + val (older, newer) = MailLogic.conversation(items, 2) + assertEquals(listOf(1L), older.map { it.id }) + assertEquals(listOf(3L, 4L), newer.map { it.id }) + // the newest open: all others above + assertEquals(listOf(1L, 2L, 3L) to emptyList(), MailLogic.conversation(items, 4).let { (a, b) -> a.map { it.id } to b.map { it.id } }) + // not in it: all above + assertEquals(4, MailLogic.conversation(items, 99).first.size) + // the same date: by id + assertEquals(listOf(5L, 6L), MailLogic.conversation(listOf(m(6, 100), m(5, 100)), 0).first.map { it.id }) + } + + @Test + fun `a conversation's senders, each once`() { + val list = listOf(s(1, 1, "a@b.de", "Anna"), s(2, 2, "tim@b.de", "Tim"), s(3, 3, "A@B.de", "Anna B."), s(4, 4, "x@y.de", null)) + assertEquals(listOf("Anna", "Tim", "x@y.de"), MailLogic.participants(list)) + assertEquals(listOf("Anna"), MailLogic.participants(list, max = 1)) + } + + // ─── a sender ─────────────────────────────────────────────────── + + @Test + fun `a sender's mails - the search in the account, page by page, exactly that address`() { + assertEquals(mapOf("from" to "anna@b.de", "box" to "3"), MailLogic.senderQuery(" Anna@b.de ", 3)) + assertEquals(mapOf("from" to "anna@b.de", "box" to "all", "cursor" to "c-1"), MailLogic.senderQuery("anna@b.de", null, "c-1")) + val items = listOf(s(1, 1, "anna@b.de"), s(2, 2, "hanna@b.de"), s(3, 3, "ANNA@b.de")) + assertEquals(listOf(1L, 3L), MailLogic.fromSender(items, "anna@b.de").map { it.id }) + } + + @Test + fun `a sender's name from the contacts - only a person's`() { + val contacts = listOf( + MContact("Anna Recent", "anna@b.de", MContact.SOURCE_RECENT), + MContact("Anna Müller", "Anna@B.de", MContact.SOURCE_PERSON), + ) + assertEquals("Anna Müller", MailLogic.contactOf(contacts, "anna@b.de")?.name) + assertNull(MailLogic.contactOf(contacts.take(1), "anna@b.de"), "someone written with lately is no contact") + assertNull(MailLogic.contactOf(listOf(MContact(null, "anna@b.de", MContact.SOURCE_PERSON)), "anna@b.de"), "without a name") + } +} diff --git a/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt b/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt index fcf50598..c34a6887 100644 --- a/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt +++ b/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt @@ -201,5 +201,7 @@ class MainTabsTest { assertEquals("mail/accounts/add", Screen.MailAddAccount.create()) assertEquals("mail/accounts/add?account=3", Screen.MailAddAccount.create(3)) assertEquals("mail/accounts/3", Screen.MailAccount.create(3)) + assertEquals("mail/sender?email=a%40b.de&name=Anna%20M%C3%BCller&account=3", Screen.MailSender.create("a@b.de", "Anna Müller", 3)) + assertEquals("mail/sender?email=a%40b.de", Screen.MailSender.create("a@b.de", " ", null)) } } diff --git a/app/src/test/java/com/gatecontrol/android/ui/mail/MailReaderViewModelsTest.kt b/app/src/test/java/com/gatecontrol/android/ui/mail/MailReaderViewModelsTest.kt new file mode 100644 index 00000000..dd3bb4ad --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/ui/mail/MailReaderViewModelsTest.kt @@ -0,0 +1,235 @@ +package com.gatecontrol.android.ui.mail + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MAddress +import com.gatecontrol.android.mail.MChanged +import com.gatecontrol.android.mail.MContact +import com.gatecontrol.android.mail.MLimits +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MMessage +import com.gatecontrol.android.mail.MMicrosoft +import com.gatecontrol.android.mail.MPicture +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSearchPage +import com.gatecontrol.android.mail.MSettings +import com.gatecontrol.android.mail.MSummary +import com.gatecontrol.android.mail.MailApi +import com.gatecontrol.android.mail.MailCache +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** The reading screen after "Bilder laden" and with its conversation; the sender's page. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MailReaderViewModelsTest { + + private val api: MailApi = mockk(relaxed = true) + private val cache: MailCache = mockk(relaxed = true) + private val repository: MailRepository = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val me = MutableStateFlow(null) + private val viewModels = mutableListOf() + + private fun summary(id: Long, date: Long = id * 100, from: String = "news@ionos.de", threadCount: Int = 1, unread: Boolean = false) = MSummary( + id = id, accountId = 1, folderId = 5, threadId = if (threadCount > 1) 77 else null, threadCount = threadCount, + from = MAddress("IONOS", from), to = emptyList(), subject = "s$id", snippet = "snip $id", date = date, unread = unread, + flagged = false, answered = false, hasAttachments = false, tab = MSummary.TAB_RELEVANT, savedToDrive = false, aiSummary = null, + ) + + private fun message(id: Long, blocked: Boolean = false, threadCount: Int = 1) = MMessage( + summary(id, threadCount = threadCount), emptyList(), emptyList(), emptyList(), + "
", "text", blocked, 0, emptyList(), null, + ) + + private fun me(threads: Boolean = true) = MMe( + features = setOf(MMe.FEATURE_MAIL, MMe.FEATURE_IMAGES), limits = MLimits(5, 0, 0, 12), + microsoft = MMicrosoft(null, "https://login.microsoftonline.com/common", emptyList()), accounts = emptyList(), + unreadAll = 0, unreadByAccount = emptyMap(), settings = MSettings.DEFAULT.copy(threads = threads), + ) + + @BeforeEach + fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + every { repository.api } returns api + every { repository.cache } returns cache + every { repository.me } returns me + every { repository.folders } returns MutableStateFlow(emptyMap()) + every { repository.changed } returns MutableSharedFlow() + coEvery { cache.message(any()) } returns null + every { context.getString(any()) } returns "text" + } + + @AfterEach + fun tearDown() { + runBlocking { viewModels.forEach { it.viewModelScope.coroutineContext[Job]!!.cancelAndJoin() } } + Dispatchers.resetMain() + } + + private fun reader(id: Long): MailReadViewModel = + MailReadViewModel(context, repository, SavedStateHandle(mapOf(Screen.MailMessage.ARG_ID to id.toString()))).also { viewModels += it } + + // ─── "Bilder laden" ───────────────────────────────────────────── + + @Test + fun `after Bilder laden the returned mail is shown and the page loads its pictures again at once`() { + me.value = me() + coEvery { api.message(1) } returns MResult.Ok(message(1, blocked = true) to JsonObject()) + coEvery { api.allowImages(1, false) } returns MResult.Ok(message(1, blocked = false) to JsonObject()) + val vm = reader(1) + val before = vm.state.value + assertTrue(before.message!!.imagesBlocked) + + vm.allowImages(sender = false) + + val after = vm.state.value + assertFalse(after.message!!.imagesBlocked, "the server's answer is the state") + assertFalse(after.imagesBusy) + // the same HTML as before (the pictures' addresses do not change): only a new round makes the WebView load again + assertEquals(before.message!!.html, after.message!!.html) + assertEquals(before.pictureRound + 1, after.pictureRound) + coVerify { cache.putMessage(any(), any()) } + } + + @Test + fun `a picture refused while blocked is not kept - it is asked for again after Bilder laden`() { + me.value = me() + coEvery { api.message(1) } returns MResult.Ok(message(1, blocked = true) to JsonObject()) + coEvery { api.picture("img/1.0.sig") } returnsMany listOf(MPicture.None, MPicture.Got(byteArrayOf(1, 2), "image/png")) + val vm = reader(1) + assertNull(runBlocking { vm.picture("img/1.0.sig") }) + assertEquals("image/png", runBlocking { vm.picture("img/1.0.sig") }!!.second) + coVerify(exactly = 2) { api.picture("img/1.0.sig") } + } + + @Test + fun `Bilder laden refused - a toast, the mail stays as it was`() { + me.value = me() + coEvery { api.message(1) } returns MResult.Ok(message(1, blocked = true) to JsonObject()) + coEvery { api.allowImages(1, true) } returns MResult.Failed("images_off", 409) + val vm = reader(1) + vm.allowImages(sender = true) + assertTrue(vm.state.value.message!!.imagesBlocked) + assertEquals(0, vm.state.value.pictureRound) + assertTrue(vm.event.value is MailReadEvent.Toast) + } + + // ─── the conversation ─────────────────────────────────────────── + + @Test + fun `the conversation loads with the mail, oldest first, and opens on a tap`() { + me.value = me(threads = true) + coEvery { api.message(2) } returns MResult.Ok(message(2, threadCount = 3) to JsonObject()) + coEvery { api.thread(77) } returns MResult.Ok(listOf(message(1, threadCount = 3), message(2, threadCount = 3), message(3, threadCount = 3))) + val vm = reader(2) + assertEquals(listOf(1L, 2L, 3L), vm.state.value.conversation.map { it.id }) + assertFalse(vm.state.value.threadOpen, "folded until tapped") + + vm.toggleThread() + assertTrue(vm.state.value.threadOpen) + vm.toggleThreadMail(1) + assertEquals(setOf(1L), vm.state.value.threadUnfolded) + vm.toggleThreadMail(1) + assertTrue(vm.state.value.threadUnfolded.isEmpty()) + vm.toggleThread() + assertFalse(vm.state.value.threadOpen) + coVerify(exactly = 1) { api.thread(77) } + } + + @Test + fun `without grouping the conversation loads only when tapped`() { + me.value = me(threads = false) + coEvery { api.message(2) } returns MResult.Ok(message(2, threadCount = 2) to JsonObject()) + coEvery { api.thread(77) } returns MResult.Ok(listOf(message(1, threadCount = 2), message(2, threadCount = 2))) + val vm = reader(2) + assertTrue(vm.state.value.conversation.isEmpty()) + coVerify(exactly = 0) { api.thread(any()) } + vm.toggleThread() + assertEquals(listOf(1L, 2L), vm.state.value.conversation.map { it.id }) + assertFalse(vm.state.value.threadLoading) + } + + @Test + fun `a single mail has no conversation to load`() { + me.value = me() + coEvery { api.message(2) } returns MResult.Ok(message(2) to JsonObject()) + val vm = reader(2) + vm.toggleThread() + coVerify(exactly = 0) { api.thread(any()) } + } + + // ─── the sender's page ────────────────────────────────────────── + + private fun sender(email: String = "news@ionos.de", name: String? = "IONOS", account: Long? = 1): MailSenderViewModel = + MailSenderViewModel( + repository, + SavedStateHandle( + mapOfNotNull( + Screen.MailSender.ARG_EMAIL to email, + Screen.MailSender.ARG_NAME to name, + Screen.MailSender.ARG_ACCOUNT to account?.toString(), + ), + ), + ).also { viewModels += it } + + private fun mapOfNotNull(vararg pairs: Pair): Map = pairs.filter { it.second != null }.toMap() + + @Test + fun `the sender's mails in the account, page by page, with the contact's name`() { + coEvery { api.contacts("news@ionos.de") } returns MResult.Ok(listOf(MContact("IONOS Kundenservice", "news@ionos.de", MContact.SOURCE_PERSON))) + coEvery { api.search(mapOf("from" to "news@ionos.de", "box" to "1")) } returns + MResult.Ok(MSearchPage(listOf(summary(9), summary(8, from = "othernews@ionos.de")), "c-8", false)) + coEvery { api.search(mapOf("from" to "news@ionos.de", "box" to "1", "cursor" to "c-8")) } returns + MResult.Ok(MSearchPage(listOf(summary(7), summary(9)), null, false)) + val vm = sender() + val s = vm.state.value + assertEquals("IONOS Kundenservice", s.label, "the contacts' name first") + assertEquals(listOf(9L), s.items.map { it.id }, "exactly this address") + assertEquals("c-8", s.next) + assertFalse(s.loading) + + vm.more() + assertEquals(listOf(9L, 7L), vm.state.value.items.map { it.id }) + assertNull(vm.state.value.next) + vm.more() + coVerify(exactly = 2) { api.search(any()) } + } + + @Test + fun `without a contact the mail's name, without that the address`() { + coEvery { api.contacts(any()) } returns MResult.Ok(listOf(MContact("Recent", "news@ionos.de", MContact.SOURCE_RECENT))) + coEvery { api.search(any()) } returns MResult.Ok(MSearchPage(emptyList(), null, false)) + assertEquals("IONOS", sender().state.value.label) + assertEquals("news@ionos.de", sender(name = null).state.value.label) + assertNull(sender().state.value.contact) + } + + @Test + fun `no address - nothing is asked`() { + val vm = sender(email = "kein-mail") + assertEquals("not_found", vm.state.value.error) + coVerify(exactly = 0) { api.search(any()) } + } +}