Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 79 additions & 5 deletions app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long, Boolean>, 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<Long, Boolean>, accountId: Long, box: Box): Map<Long, Boolean> =
folds + (accountId to !foldOpen(folds, accountId, box))

// ─── a conversation ─────────────────────────────────────────────

/**
* A conversation (`GET threads/<id>`) 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<MMessage>, currentId: Long): Pair<List<MMessage>, List<MMessage>> {
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<MSummary>, max: Int = 3): List<String> =
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<String, String> = 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<MSummary>, email: String): List<MSummary> = 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<MContact>, 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
Expand Down Expand Up @@ -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 "<!doctype html><html><head><meta charset=\"utf-8\">" +
"<meta http-equiv=\"Content-Security-Policy\" content=\"$csp\">" +
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
"<style>html,body{margin:0;padding:0;$colors}body{padding:12px;font-family:sans-serif;font-size:15px;line-height:1.5;" +
"<style>html,body{margin:0;padding:0;$colors}body{padding:${PAGE_PADDING}px;font-family:sans-serif;font-size:15px;line-height:1.5;" +
"word-wrap:break-word;overflow-wrap:anywhere;background:#FFFFFF;color:#1A1D22}" +
"img{max-width:100%;height:auto}table{max-width:100%}pre{white-space:pre-wrap}</style></head><body>" +
html + "</body></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</style></head><body>" +
"<div id=\"gc-fit\">" + html + "</div></body></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 ────────────────────────────────────────
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/java/com/gatecontrol/android/mail/MailModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,6 +64,15 @@ class MailRepository @Inject constructor(
private val _undo = MutableStateFlow<MUndo?>(null)
val undo: StateFlow<MUndo?> = _undo.asStateFlow()

/** The menu's folded accounts (account id → folders shown), kept while the app runs. */
private val _folds = MutableStateFlow<Map<Long, Boolean>>(emptyMap())
val folds: StateFlow<Map<Long, Boolean>> = _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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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)) },
),
)
}
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/com/gatecontrol/android/navigation/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading
Loading