feat(compose): remember last-used font and size #197
@@ -73,6 +73,14 @@ data class AppSettings(
|
||||
* but exits (or whose process dies) before adding an account isn't forced to agree again.
|
||||
*/
|
||||
val licenseAccepted: Boolean = false,
|
||||
/**
|
||||
* The font family CSS / point size last used in a sent formatted message (issue #78), so the next
|
||||
* brand-new composition ([org.libremail.ui.compose.ComposeViewModel]) can default to it. Either
|
||||
* may be null on its own (e.g. only a size was ever recorded); both null means nothing has been
|
||||
* remembered yet, so new messages stay plaintext exactly as before this preference existed.
|
||||
*/
|
||||
val lastFontCss: String? = null,
|
||||
val lastFontSizePt: Int? = null,
|
||||
)
|
||||
|
||||
private object Keys {
|
||||
@@ -89,6 +97,8 @@ private object Keys {
|
||||
val RETENTION_MONTHS = intPreferencesKey("retention_months")
|
||||
val DEFAULT_ACCOUNT_ID = stringPreferencesKey("default_account_id")
|
||||
val LICENSE_ACCEPTED = booleanPreferencesKey("license_accepted")
|
||||
val LAST_FONT_CSS = stringPreferencesKey("last_font_css")
|
||||
val LAST_FONT_SIZE_PT = intPreferencesKey("last_font_size_pt")
|
||||
val BATTERY_PROMPT_HANDLED = booleanPreferencesKey("battery_prompt_handled")
|
||||
val CONTACTS_PROMPT_HANDLED = booleanPreferencesKey("contacts_prompt_handled")
|
||||
val CONTACTS_PERMISSION_REQUESTED = booleanPreferencesKey("contacts_permission_requested")
|
||||
@@ -116,6 +126,8 @@ internal fun Preferences.toAppSettings(): AppSettings = AppSettings(
|
||||
retentionMonths = this[Keys.RETENTION_MONTHS] ?: 0,
|
||||
defaultAccountId = this[Keys.DEFAULT_ACCOUNT_ID],
|
||||
licenseAccepted = this[Keys.LICENSE_ACCEPTED] ?: false,
|
||||
lastFontCss = this[Keys.LAST_FONT_CSS],
|
||||
lastFontSizePt = this[Keys.LAST_FONT_SIZE_PT],
|
||||
)
|
||||
|
||||
@Singleton
|
||||
@@ -221,6 +233,18 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the font family/size last used in a sent formatted message (#78), so the next brand-new
|
||||
* composition can default to it. Either argument may be null on its own; passing null for one
|
||||
* clears just that key rather than persisting a sentinel value.
|
||||
*/
|
||||
suspend fun setLastFont(fontCss: String?, fontSizePt: Int?) {
|
||||
context.settingsDataStore.edit {
|
||||
if (fontCss != null) it[Keys.LAST_FONT_CSS] = fontCss else it.remove(Keys.LAST_FONT_CSS)
|
||||
if (fontSizePt != null) it[Keys.LAST_FONT_SIZE_PT] = fontSizePt else it.remove(Keys.LAST_FONT_SIZE_PT)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun put(key: Preferences.Key<Boolean>, value: Boolean) {
|
||||
context.settingsDataStore.edit { it[key] = value }
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import org.libremail.domain.model.OutgoingAttachment
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.richtext.RichBaseStyle
|
||||
import org.libremail.richtext.RichStyle
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import org.libremail.ui.navigation.Routes
|
||||
@@ -122,16 +124,20 @@ class ComposeViewModel @Inject constructor(
|
||||
// signature. Reply/forward drafts already carry theirs, so they take the draft branch above.
|
||||
viewModelScope.launch {
|
||||
val available = accountRepository.observeAccounts().first { it.isNotEmpty() }
|
||||
val settings = settingsRepository.settings.first()
|
||||
// The persisted default (#163) only counts if it still names an account that exists.
|
||||
// Deleting the default account normally clears this via
|
||||
// SettingsRepository.clearDefaultAccountId, but a stale id could still reach here (e.g.
|
||||
// a Backup restore onto a device that never had the account) — validate rather than
|
||||
// trust it, so it just falls through to the incidental "first account alphabetically"
|
||||
// behavior instead of crashing or composing from a nonexistent account.
|
||||
val defaultAccountId = settingsRepository.settings.first().defaultAccountId
|
||||
val validDefaultAccountId = defaultAccountId?.takeIf { id -> available.any { it.id == id } }
|
||||
val validDefaultAccountId = settings.defaultAccountId?.takeIf { id -> available.any { it.id == id } }
|
||||
val effectiveId = _state.value.fromAccountId ?: validDefaultAccountId ?: available.first().id
|
||||
applySignature(effectiveId)
|
||||
// Seed the message-wide default font (#78) after the signature is applied, so the whole
|
||||
// body — including the signature just appended — picks it up. New compositions only:
|
||||
// replies/forwards resume as drafts and take the branch above, left untouched.
|
||||
seedRememberedFont(settings.lastFontCss, settings.lastFontSizePt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +207,45 @@ class ComposeViewModel @Inject constructor(
|
||||
/** Keeps an HTML body only when it actually carries formatting, so plaintext stays plaintext. */
|
||||
private fun normalizedHtml(html: String): String? =
|
||||
if (html.isBlank() || !RichTextHtml.fromHtml(html).hasFormatting()) null else html
|
||||
|
||||
/**
|
||||
* Seeds a brand-new composition with the last-remembered font (#78) by wrapping the current body
|
||||
* in a [RichBaseStyle], so the whole message — including any signature just applied — defaults to
|
||||
* it. A no-op when nothing has been remembered yet, so a message that would otherwise stay
|
||||
* plaintext-only isn't forced into an HTML body for no visible reason (an empty [RichBaseStyle]
|
||||
* still flips [RichTextContent.hasFormatting]).
|
||||
*/
|
||||
private fun seedRememberedFont(fontCss: String?, fontSizePt: Int?) {
|
||||
if (fontCss == null && fontSizePt == null) return
|
||||
_state.update { s ->
|
||||
val content = (s.bodyHtml?.let(RichTextHtml::fromHtml) ?: RichTextContent(text = s.body))
|
||||
.copy(baseStyle = RichBaseStyle(fontCss, fontSizePt))
|
||||
s.copy(bodyHtml = RichTextHtml.toHtml(content))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the font used in a just-sent formatted message (#78): the message-wide base style if
|
||||
* one is set, else the last `FontFamily`/`FontSize` span in the body (the formatting nearest the
|
||||
* end of the message). A no-op for a plaintext send ([bodyHtml] null) or a formatted one that never
|
||||
* touched the font — an unrelated send must never clear a previously remembered preference.
|
||||
*/
|
||||
private suspend fun rememberFontPreference(bodyHtml: String?) {
|
||||
val html = bodyHtml ?: return
|
||||
val (fontCss, fontSizePt) = lastFontIn(RichTextHtml.fromHtml(html)) ?: return
|
||||
settingsRepository.setLastFont(fontCss, fontSizePt)
|
||||
}
|
||||
|
||||
/** The base style if set, else the last matching inline font span; null when neither is present. */
|
||||
private fun lastFontIn(content: RichTextContent): Pair<String?, Int?>? {
|
||||
content.baseStyle?.let { return it.fontCss to it.fontSizePt }
|
||||
val css = content.spans.lastOrNull { it.style is RichStyle.FontFamily }
|
||||
?.let { (it.style as RichStyle.FontFamily).css }
|
||||
val sizePt = content.spans.lastOrNull { it.style is RichStyle.FontSize }
|
||||
?.let { (it.style as RichStyle.FontSize).pt }
|
||||
return if (css != null || sizePt != null) css to sizePt else null
|
||||
}
|
||||
|
||||
fun addAttachments(items: List<OutgoingAttachment>) =
|
||||
_state.update { it.copy(attachments = it.attachments + items) }
|
||||
fun removeAttachment(uri: String) = _state.update {
|
||||
@@ -331,6 +376,7 @@ class ComposeViewModel @Inject constructor(
|
||||
),
|
||||
).fold(
|
||||
onSuccess = {
|
||||
rememberFontPreference(s.bodyHtml)
|
||||
draftId?.let { mailRepository.deleteDraft(it) }
|
||||
_state.update { it.copy(sending = false) }
|
||||
finish()
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.libremail.data.settings
|
||||
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.preferencesOf
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import org.junit.Test
|
||||
@@ -16,11 +17,17 @@ import kotlin.test.assertEquals
|
||||
* Also covers `licenseAccepted` (#172), the same kind of default/fallback mapping but for a plain
|
||||
* boolean: unset must read back as "not yet accepted" so a fresh install (and any pre-#172 install
|
||||
* that never wrote the key) is routed through `Routes.ONBOARDING_LICENSE` rather than skipping it.
|
||||
*
|
||||
* Also covers `lastFontCss`/`lastFontSizePt` (#78): the last-used compose font/size, read back by
|
||||
* `ComposeViewModel` to seed brand-new compositions. Both are plain nullable round-trips — unset
|
||||
* must read back as null (never a font is a valid, common state), not some sentinel.
|
||||
*/
|
||||
class AppSettingsTest {
|
||||
|
||||
private val fetchPolicyKey = stringPreferencesKey("fetch_policy")
|
||||
private val licenseAcceptedKey = booleanPreferencesKey("license_accepted")
|
||||
private val lastFontCssKey = stringPreferencesKey("last_font_css")
|
||||
private val lastFontSizePtKey = intPreferencesKey("last_font_size_pt")
|
||||
|
||||
@Test
|
||||
fun `in-memory default fetch policy is WIFI_ONLY`() {
|
||||
@@ -59,4 +66,33 @@ class AppSettingsTest {
|
||||
val prefs = preferencesOf(licenseAcceptedKey to true)
|
||||
assertEquals(true, prefs.toAppSettings().licenseAccepted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in-memory default has no remembered font`() {
|
||||
assertEquals(null, AppSettings().lastFontCss)
|
||||
assertEquals(null, AppSettings().lastFontSizePt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a never-set font preference reads back as null`() {
|
||||
val settings = emptyPreferences().toAppSettings()
|
||||
assertEquals(null, settings.lastFontCss)
|
||||
assertEquals(null, settings.lastFontSizePt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a persisted font family and size round-trip independently`() {
|
||||
val prefs = preferencesOf(lastFontCssKey to "Georgia, serif", lastFontSizePtKey to 14)
|
||||
val settings = prefs.toAppSettings()
|
||||
assertEquals("Georgia, serif", settings.lastFontCss)
|
||||
assertEquals(14, settings.lastFontSizePt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the size can be set while the family stays null`() {
|
||||
val prefs = preferencesOf(lastFontSizePtKey to 18)
|
||||
val settings = prefs.toAppSettings()
|
||||
assertEquals(null, settings.lastFontCss)
|
||||
assertEquals(18, settings.lastFontSizePt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
package org.libremail.ui.compose
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -33,6 +35,8 @@ import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.domain.model.Signature
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.richtext.RichBaseStyle
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
@@ -70,6 +74,9 @@ class ComposeViewModelTest {
|
||||
settings: Map<String, AccountSettings> = emptyMap(),
|
||||
mailRepository: MailRepository = mockk(relaxed = true),
|
||||
defaultAccountId: String? = null,
|
||||
lastFontCss: String? = null,
|
||||
lastFontSizePt: Int? = null,
|
||||
settingsRepository: SettingsRepository = fakeSettingsRepository(defaultAccountId, lastFontCss, lastFontSizePt),
|
||||
): ComposeViewModel {
|
||||
val accountRepository = mockk<AccountRepository>()
|
||||
every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts)
|
||||
@@ -80,8 +87,6 @@ class ComposeViewModelTest {
|
||||
}
|
||||
val signatureRepository = mockk<SignatureRepository>()
|
||||
coEvery { signatureRepository.getDefault(any()) } answers { signatures[firstArg<String>()] }
|
||||
val settingsRepository = mockk<SettingsRepository>()
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(defaultAccountId = defaultAccountId))
|
||||
return ComposeViewModel(
|
||||
savedStateHandle = savedState,
|
||||
mailRepository = mailRepository,
|
||||
@@ -93,6 +98,26 @@ class ComposeViewModelTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A [SettingsRepository] mock carrying [AppSettings] built from the given fields, with
|
||||
* [SettingsRepository.setLastFont] stubbed so callers that don't care about it (most tests) don't need to.
|
||||
*/
|
||||
private fun fakeSettingsRepository(
|
||||
defaultAccountId: String? = null,
|
||||
lastFontCss: String? = null,
|
||||
lastFontSizePt: Int? = null,
|
||||
): SettingsRepository {
|
||||
val repository = mockk<SettingsRepository>()
|
||||
val appSettings = AppSettings(
|
||||
defaultAccountId = defaultAccountId,
|
||||
lastFontCss = lastFontCss,
|
||||
lastFontSizePt = lastFontSizePt,
|
||||
)
|
||||
every { repository.settings } returns flowOf(appSettings)
|
||||
coEvery { repository.setLastFont(any(), any()) } just Runs
|
||||
return repository
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `appends the sending account default signature to a new message`() = runTest(testDispatcher) {
|
||||
val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")))
|
||||
@@ -171,6 +196,38 @@ class ComposeViewModelTest {
|
||||
assertEquals("", vm.state.value.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seeds a brand-new composition with the remembered font`() = runTest(testDispatcher) {
|
||||
// No signature configured, so the body is empty going in — the seeded base style still wraps it.
|
||||
val vm = viewModel(lastFontCss = "Georgia, serif", lastFontSizePt = 14)
|
||||
|
||||
val html = vm.state.value.bodyHtml
|
||||
assertTrue(html != null, "expected a seeded HTML body, was null")
|
||||
assertEquals(RichBaseStyle("Georgia, serif", 14), RichTextHtml.fromHtml(html).baseStyle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seeds the remembered font onto a new message's signature`() = runTest(testDispatcher) {
|
||||
val vm = viewModel(
|
||||
signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")),
|
||||
lastFontCss = "Georgia, serif",
|
||||
lastFontSizePt = null,
|
||||
)
|
||||
|
||||
val content = RichTextHtml.fromHtml(vm.state.value.bodyHtml!!)
|
||||
assertEquals(RichBaseStyle("Georgia, serif", null), content.baseStyle)
|
||||
// The signature text itself must still be present, just wrapped by the base style.
|
||||
assertTrue(content.text.contains("Cheers, Alice"), "text=${content.text}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stays plaintext-only when composing new with no remembered font`() = runTest(testDispatcher) {
|
||||
val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")))
|
||||
|
||||
// No font preference stored: the plain signature must not gain an HTML body just from seeding.
|
||||
assertNull(vm.state.value.bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not append a signature when resuming a draft`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
@@ -196,6 +253,32 @@ class ComposeViewModelTest {
|
||||
assertEquals("<p>Draft <b>body</b></p>", vm.state.value.bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not seed the remembered font onto an existing draft`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.getDraft("d1") } returns Draft(
|
||||
id = "d1",
|
||||
accountId = "imap:a",
|
||||
to = "x@example.org",
|
||||
cc = "",
|
||||
subject = "Hi",
|
||||
body = "Draft body",
|
||||
updatedAt = 0L,
|
||||
bodyHtml = "<p>Draft <b>body</b></p>",
|
||||
attachments = emptyList(),
|
||||
)
|
||||
val vm = viewModel(
|
||||
savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")),
|
||||
mailRepository = mailRepository,
|
||||
lastFontCss = "Georgia, serif",
|
||||
lastFontSizePt = 14,
|
||||
)
|
||||
|
||||
assertEquals("Draft body", vm.state.value.body)
|
||||
// Only brand-new compositions seed the remembered font; a resumed draft's HTML is untouched.
|
||||
assertEquals("<p>Draft <b>body</b></p>", vm.state.value.bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resuming a draft restores the bcc recipients`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
@@ -232,6 +315,67 @@ class ComposeViewModelTest {
|
||||
assertEquals("Hello", sent.captured.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persists the message-wide base style font on send`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val settingsRepository = fakeSettingsRepository()
|
||||
val vm = viewModel(mailRepository = mailRepository, settingsRepository = settingsRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("Hello", "<div style=\"font-family:Georgia, serif;font-size:14pt\"><p>Hello</p></div>")
|
||||
vm.send()
|
||||
|
||||
coVerify { settingsRepository.setLastFont("Georgia, serif", 14) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persists the last font span when the message has no base style`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val settingsRepository = fakeSettingsRepository()
|
||||
val vm = viewModel(mailRepository = mailRepository, settingsRepository = settingsRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange(
|
||||
"Hello there",
|
||||
"<p><span style=\"font-family:Arial\">Hello</span> " +
|
||||
"<span style=\"font-size:18pt\">there</span></p>",
|
||||
)
|
||||
vm.send()
|
||||
|
||||
// Each axis independently takes the last matching span: family from "Hello", size from "there".
|
||||
coVerify { settingsRepository.setLastFont("Arial", 18) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not persist anything when the sent message has no font styling`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val settingsRepository = fakeSettingsRepository()
|
||||
val vm = viewModel(mailRepository = mailRepository, settingsRepository = settingsRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("Hello world", "<p>Hello <b>world</b></p>")
|
||||
vm.send()
|
||||
|
||||
coVerify(exactly = 0) { settingsRepository.setLastFont(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not persist anything for a plaintext-only send`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val settingsRepository = fakeSettingsRepository()
|
||||
val vm = viewModel(mailRepository = mailRepository, settingsRepository = settingsRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("Hello world", null)
|
||||
vm.send()
|
||||
|
||||
coVerify(exactly = 0) { settingsRepository.setLastFont(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prefills the form from mailto navigation arguments`() = runTest(testDispatcher) {
|
||||
val vm = viewModel(
|
||||
|
||||
Reference in New Issue
Block a user