Skip to content

Commit e015e09

Browse files
CallMeTechieClaude
andauthored
Mail: breite Mails passen, Bilder sofort, Ordner auf- und zuklappen, Unterhaltung, Absender (#161)
* feat(mail): Konten wie in der Outlook-App, später geladene Inhalte und Anhänge (Mail 1.1.2) - Account.avatar { mediaId, mime } (nur Microsoft): MailAvatars lädt das Bild über media/<mediaId>, hält es im Speicher und in der Offline-Kopie an der mediaId - MailAccountAvatar: Profilbild oder eigenes einfaches Anbieterzeichen („O“, vier Quadrate, „G“, „GMX“, „W“, „T“, sonst „@“ in Kontofarbe), kleiner Punkt in Kontofarbe; im Seitenmenü mit Name und Adresse, in den Mail-Einstellungen, auf der Kontoseite und bei der Absender-Auswahl - Live-Ereignis mail mit messageId, threadId, attachmentId - bodyMissing: „Der Inhalt wird noch vom Anbieter geladen …“ mit „Erneut versuchen“ und der Vorschau; das Ereignis mit derselben messageId oder threadId lädt die Mail neu; die Vorschau ersetzt keine volle Kopie - 409 busy (loading) bei Anhängen: „Wird gerade geladen …“, Neuversuch nach retryAfter (höchstens 3-mal) oder beim Ereignis mit attachmentId; eingebettete Bilder laden danach neu - Tests: MailJson (Avatar, Ereignis, busy), MailLogic (Zeichen, Ereignis, Wartezeit, cid) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD * feat(mail): breite Mails passen, Bilder sofort, Ordner auf- und zuklappen, Unterhaltung, Absender - Breite Mails (Newsletter mit fester Tabellenbreite) passen in die Breite: Bilder, Tabellen, lange Links und pre bleiben im Bildschirm; was danach noch breiter ist, misst die WebView von außen (Inhaltsbreite, kein Skript) und die Mail wird per CSS zoom kleiner gesetzt (MailLogic.fitZoom, höchstens 3 Runden, nie unter 25 %). Aufziehen zum Vergrößern bleibt möglich, die Mail selbst bleibt ohne Skript (JavaScript aus, CSP script-src 'none') - „Bilder laden“: das HTML bleibt dasselbe (img/<token>), daher lud die WebView nicht neu. Jetzt übernimmt der State die zurückgegebene Mail und zählt pictureRound hoch; Antworten der Seite gehen mit Cache-Control: no-store, verweigerte Bilder werden nie gehalten - Seitenmenü: ein Tipp auf die Kontozeile (mit Pfeil) klappt den Ordnerbaum auf und zu, das Menü bleibt offen; „Posteingang“ steht immer im Baum, erst die Wahl eines Ordners schließt das Menü. Der Klappzustand pro Konto bleibt für die Sitzung (MailRepository.folds) - Unterhaltung: „Unterhaltung · n Mails“ mit den Absendern; ein Tipp zeigt alle Mails (GET threads/<id>), älteste zuerst, die geöffnete aufgeklappt, die neueste unten; jede andere Mail mit Absender, Datum und Vorschau aufklappbar, mit „Mail öffnen“ - Absender antippen: Absender-Seite mit Initialen, Name, Adresse, „Mail schreiben“ und „Adresse kopieren“, darunter seine Mails im Konto (GET search?from=, Seiten über den Cursor); Name aus contacts?q= bei source 'person'; Platz für die Kontaktdaten des geplanten Kontakte-Plugins - Tests: Seitenaufbau und Skalierung, Klappzustand, Unterhaltung, Absender (MailLogic), State nach „Bilder laden“, Laden der Unterhaltung und der Absender-Mails (ViewModels), Route der Absender-Seite Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1250093 commit e015e09

18 files changed

Lines changed: 1092 additions & 86 deletions

File tree

‎app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt‎

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,51 @@ object MailLogic {
7474
/** Whether a mail still belongs to the list it is in after [action] (archive, delete, junk, move take it away). */
7575
fun leavesList(action: String): Boolean = action in setOf(SWIPE_ARCHIVE, SWIPE_DELETE, "junk", "move", "tab")
7676

77+
// ─── the menu: an account's folders ─────────────────────────────
78+
79+
/**
80+
* Whether the menu shows [accountId]'s folders: as the person folded it this session ([folds]), else open for the
81+
* account of the [box] shown.
82+
*/
83+
fun foldOpen(folds: Map<Long, Boolean>, accountId: Long, box: Box): Boolean = folds[accountId] ?: (box.accountId == accountId)
84+
85+
/** [folds] after a tap on the account (its folders open or close; the menu stays). */
86+
fun toggleFold(folds: Map<Long, Boolean>, accountId: Long, box: Box): Map<Long, Boolean> =
87+
folds + (accountId to !foldOpen(folds, accountId, box))
88+
89+
// ─── a conversation ─────────────────────────────────────────────
90+
91+
/**
92+
* A conversation (`GET threads/<id>`) around the open mail [currentId]: the mails before it and those after it,
93+
* each oldest first (the newest at the bottom, as in Outlook). Without the open mail in it, all count as before.
94+
*/
95+
fun conversation(items: List<MMessage>, currentId: Long): Pair<List<MMessage>, List<MMessage>> {
96+
val sorted = items.distinctBy { it.id }.sortedWith(compareBy({ it.summary.date }, { it.id }))
97+
val at = sorted.indexOfFirst { it.id == currentId }
98+
if (at < 0) return sorted to emptyList()
99+
return sorted.subList(0, at) to sorted.subList(at + 1, sorted.size)
100+
}
101+
102+
/** The senders of a conversation for its line ("Anna, Tim"), each once, at most [max]. */
103+
fun participants(items: List<MSummary>, max: Int = 3): List<String> =
104+
items.map { it.from }.distinctBy { it.email.lowercase() }.map { it.label }.filter { it.isNotBlank() }.take(max)
105+
106+
// ─── a sender ───────────────────────────────────────────────────
107+
108+
/** `GET search` for the mails of [email] in the account [accountId] (all accounts without one). */
109+
fun senderQuery(email: String, accountId: Long?, cursor: String? = null): Map<String, String> = buildMap {
110+
put("from", email.trim().lowercase())
111+
put("box", accountId?.toString() ?: BOX_ALL)
112+
cursor?.let { put("cursor", it) }
113+
}
114+
115+
/** The server's `from` matches a part of the address: only the mails of exactly [email] stay. */
116+
fun fromSender(items: List<MSummary>, email: String): List<MSummary> = items.filter { it.from.email.trim().equals(email.trim(), ignoreCase = true) }
117+
118+
/** The name of [email] in the person's contacts (`contacts?q=`, a `person` hit), null when there is none. */
119+
fun contactOf(contacts: List<MContact>, email: String): MContact? =
120+
contacts.firstOrNull { it.source == MContact.SOURCE_PERSON && it.email.trim().equals(email.trim(), ignoreCase = true) && !it.name.isNullOrBlank() }
121+
77122
// ─── swipes ─────────────────────────────────────────────────────
78123

79124
const val SWIPE_ARCHIVE = MSettings.SWIPE_ARCHIVE
@@ -311,21 +356,50 @@ object MailLogic {
311356
return parseRecipients(addr).addresses.firstOrNull()
312357
}
313358

359+
/** The page's margin (CSS px) around the mail. */
360+
const val PAGE_PADDING = 12
361+
362+
/** Below this a mail is not made any smaller to fit (pinching still zooms in). */
363+
const val MIN_ZOOM = 0.25
364+
365+
/** How often a page is laid out again to fit (each time only smaller, so it always ends). */
366+
const val FIT_ROUNDS = 3
367+
314368
/**
315369
* The page the WebView shows: the mail's (already cleaned) HTML with a strict content policy (no scripts, no
316-
* frames, pictures only from the app's own address) and a width that fits the phone.
370+
* frames, pictures only from the app's own address) and a width that fits the phone: pictures, tables, long
371+
* links and preformatted text stay inside the screen. What is still wider (a newsletter's table of a fixed 600 px)
372+
* is made smaller as a whole with [zoom] (CSS `zoom` on the mail, as mail apps do), measured from outside by
373+
* [fitZoom] — the page itself never runs a script. Pinching still zooms in.
317374
*/
318-
fun page(html: String, dark: Boolean = false): String {
375+
fun page(html: String, dark: Boolean = false, zoom: Double = 1.0): String {
319376
val csp = "default-src 'none'; img-src $BASE_URL data:; style-src 'unsafe-inline'; font-src data:; " +
320377
"script-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'"
321378
val colors = if (dark) "color-scheme: light;" else ""
379+
val z = zoom.coerceIn(MIN_ZOOM, 1.0)
380+
val fit = if (z < 1.0) "#gc-fit{zoom:${String.format(java.util.Locale.ROOT, "%.3f", z)}}" else ""
322381
return "<!doctype html><html><head><meta charset=\"utf-8\">" +
323382
"<meta http-equiv=\"Content-Security-Policy\" content=\"$csp\">" +
324383
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
325-
"<style>html,body{margin:0;padding:0;$colors}body{padding:12px;font-family:sans-serif;font-size:15px;line-height:1.5;" +
384+
"<style>html,body{margin:0;padding:0;$colors}body{padding:${PAGE_PADDING}px;font-family:sans-serif;font-size:15px;line-height:1.5;" +
326385
"word-wrap:break-word;overflow-wrap:anywhere;background:#FFFFFF;color:#1A1D22}" +
327-
"img{max-width:100%;height:auto}table{max-width:100%}pre{white-space:pre-wrap}</style></head><body>" +
328-
html + "</body></html>"
386+
"img{max-width:100%;height:auto}table{max-width:100%}td,th{overflow-wrap:anywhere}" +
387+
"a{overflow-wrap:anywhere;word-break:break-word}pre{white-space:pre-wrap;overflow-wrap:anywhere}$fit</style></head><body>" +
388+
"<div id=\"gc-fit\">" + html + "</div></body></html>"
389+
}
390+
391+
/**
392+
* The [page]'s zoom so the mail fits without scrolling sideways, from what the WebView measured: [viewCss] its
393+
* width and [contentCss] the width of what it shows (both in CSS px, the content at the [current] zoom). Null
394+
* when it fits already (1 px of rounding allowed) or would not get smaller; never below [MIN_ZOOM].
395+
*/
396+
fun fitZoom(viewCss: Double, contentCss: Double, current: Double = 1.0): Double? {
397+
if (viewCss <= 2 * PAGE_PADDING || contentCss <= viewCss + 1.0 || current <= MIN_ZOOM) return null
398+
// the mail at zoom 1 (the margin on the left is not zoomed; one on the right may count or not)
399+
val mail = (contentCss - PAGE_PADDING) / current
400+
val zoom = ((viewCss - 2 * PAGE_PADDING) / mail).coerceIn(MIN_ZOOM, 1.0)
401+
val rounded = kotlin.math.floor(zoom * 1000) / 1000
402+
return rounded.takeIf { it < current - 0.001 }
329403
}
330404

331405
// ─── the body of a draft ────────────────────────────────────────

‎app/src/main/java/com/gatecontrol/android/mail/MailModels.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,13 @@ data class MOutboxItem(
320320
val error: String?,
321321
)
322322

323-
data class MContact(val name: String?, val email: String, val source: String)
323+
data class MContact(val name: String?, val email: String, val source: String) {
324+
companion object {
325+
/** A contact of the person (the coming contacts plugin); `recent`: someone written with lately. */
326+
const val SOURCE_PERSON = "person"
327+
const val SOURCE_RECENT = "recent"
328+
}
329+
}
324330

325331
/** "Zum Ablegen": an attachment the AI was not sure about. */
326332
data class MFilingItem(

‎app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow
2828
import kotlinx.coroutines.flow.distinctUntilChanged
2929
import kotlinx.coroutines.flow.drop
3030
import kotlinx.coroutines.flow.map
31+
import kotlinx.coroutines.flow.update
3132
import kotlinx.coroutines.launch
3233
import timber.log.Timber
3334
import javax.inject.Inject
@@ -63,6 +64,15 @@ class MailRepository @Inject constructor(
6364
private val _undo = MutableStateFlow<MUndo?>(null)
6465
val undo: StateFlow<MUndo?> = _undo.asStateFlow()
6566

67+
/** The menu's folded accounts (account id → folders shown), kept while the app runs. */
68+
private val _folds = MutableStateFlow<Map<Long, Boolean>>(emptyMap())
69+
val folds: StateFlow<Map<Long, Boolean>> = _folds.asStateFlow()
70+
71+
/** A tap on an account in the menu: its folders open or close ([MailLogic.toggleFold]). */
72+
fun toggleFold(accountId: Long, box: MailLogic.Box) {
73+
_folds.update { MailLogic.toggleFold(it, accountId, box) }
74+
}
75+
6676
@Volatile private var meAt = 0L
6777
private var refreshJob: Job? = null
6878

‎app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import com.gatecontrol.android.ui.mail.MailOutboxScreen
1616
import com.gatecontrol.android.ui.mail.MailReadNav
1717
import com.gatecontrol.android.ui.mail.MailReadScreen
1818
import com.gatecontrol.android.ui.mail.MailSearchScreen
19+
import com.gatecontrol.android.ui.mail.MailSenderNav
20+
import com.gatecontrol.android.ui.mail.MailSenderScreen
1921
import com.gatecontrol.android.ui.mail.MailSettingsScreen
2022

2123
/** 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(
7173
compose = { mode, reply -> if (id != null) go(Screen.MailCompose.create(ref = id, mode = mode, reply = reply)) },
7274
mailTo = { to -> go(Screen.MailCompose.create(to = to)) },
7375
open = { other -> navController.navigate(Screen.MailMessage.create(other)) },
76+
sender = { email, name, account -> go(Screen.MailSender.create(email, name, account)) },
77+
),
78+
)
79+
}
80+
}
81+
composable(Screen.MailSender.route, arguments = listOf(optional(Screen.MailSender.ARG_EMAIL), optional(Screen.MailSender.ARG_NAME), optional(Screen.MailSender.ARG_ACCOUNT))) {
82+
GcContentWidth {
83+
MailSenderScreen(
84+
MailSenderNav(
85+
back = back,
86+
write = { to, account -> go(Screen.MailCompose.create(account = account, to = to)) },
87+
open = { id -> navController.navigate(Screen.MailMessage.create(id)) },
7488
),
7589
)
7690
}

‎app/src/main/java/com/gatecontrol/android/navigation/Screen.kt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,17 @@ sealed class Screen(val route: String) {
160160
}
161161
}
162162
data object MailSearch : Screen("mail/search")
163+
/** A sender's page: their mails in the account `account`. */
164+
data object MailSender : Screen("mail/sender?email={email}&name={name}&account={account}") {
165+
const val ARG_EMAIL = "email"
166+
const val ARG_NAME = "name"
167+
const val ARG_ACCOUNT = "account"
168+
fun create(email: String, name: String? = null, account: Long? = null): String {
169+
fun enc(v: String) = java.net.URLEncoder.encode(v, "UTF-8").replace("+", "%20")
170+
val args = listOfNotNull("email=" + enc(email), name?.takeIf { it.isNotBlank() }?.let { "name=" + enc(it.take(200)) }, account?.let { "account=$it" })
171+
return "mail/sender?" + args.joinToString("&")
172+
}
173+
}
163174
/** "Zum Ablegen": attachments the AI was not sure about. */
164175
data object MailFiling : Screen("mail/filing")
165176
/** "Konto hinzufügen"; `account` = sign in to that Microsoft account again. */

‎app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,9 @@ internal fun FileBadge(name: String, mime: String, size: Int = 40) {
235235

236236
/** The round avatar with the sender's initials in the account's color. */
237237
@Composable
238-
internal fun SenderAvatar(name: String?, email: String, color: Color) {
239-
Box(Modifier.size(40.dp).clip(CircleShape).background(color), contentAlignment = Alignment.Center) {
240-
Text(MailLogic.initials(name, email), color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = 14.sp)
238+
internal fun SenderAvatar(name: String?, email: String, color: Color, size: Dp = 40.dp) {
239+
Box(Modifier.size(size).clip(CircleShape).background(color), contentAlignment = Alignment.Center) {
240+
Text(MailLogic.initials(name, email), color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = (size.value * 0.35f).sp)
241241
}
242242
}
243243

0 commit comments

Comments
 (0)