fix(ui): reader Reply quotes original + guard one-shot actions against double-tap

#303: the reader's Reply now routes through MailRepository.buildReplyDraft
(quotes the original into a <blockquote>, bakes the signature, prefixes Re:/Fwd:
without double-prefixing) and opens compose on the built draft via
ReaderEvent.OpenCompose — the same high-fidelity path the mailbox uses — instead
of a bare compose prefill with an empty body. Adds Reply-All and Forward via an
app-bar overflow menu.

#304: SignatureEditViewModel.save, ReportReviewViewModel.submit,
AccountSettingsViewModel.removeAccount, and ProblemReportsViewModel.createManualReport
now flip a busy/saving flag synchronously before the first suspension and gate
their buttons, so a rapid double-tap can't create duplicate signatures/reports,
enqueue two uploads, or over-pop the back stack.

Closes #303
Closes #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 02:36:15 -05:00
co-authored by Claude Opus 4.8
parent b64c7f8dda
commit 6245533368
18 changed files with 353 additions and 22 deletions
@@ -11,6 +11,7 @@ import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -18,6 +19,7 @@ import org.libremail.R
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import org.libremail.ui.FakeMailRepository
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
@@ -44,8 +46,17 @@ class ReaderScreenTest {
private fun attachment(partIndex: Int, filename: String) =
Attachment(messageId, partIndex, filename, "application/pdf", 1_000L)
/** Renders [ReaderScreen] for the fixed [message] with the given [attachments] and awaits load. */
private fun renderReader(attachments: List<Attachment>, downloadedParts: Set<Int> = emptySet()) {
/** The draft id the reader last asked to open compose on (via OpenCompose), or null. */
private var openedDraftId: String? = null
/**
* Renders [ReaderScreen] for the fixed [message] with the given [attachments], awaits load, and
* returns the backing [FakeMailRepository] so a test can assert which reply drafts were built.
*/
private fun renderReader(
attachments: List<Attachment> = emptyList(),
downloadedParts: Set<Int> = emptySet(),
): FakeMailRepository {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val repo = FakeMailRepository(
messages = listOf(message),
@@ -59,12 +70,14 @@ class ReaderScreenTest {
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel)
ReaderScreen(onBack = {}, onOpenCompose = { openedDraftId = it }, viewModel = viewModel)
}
}
// The Reply action appears once the message loads, regardless of whether it has attachments.
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(attachments.first().filename).fetchSemanticsNodes().isNotEmpty()
composeTestRule.onAllNodesWithText(string(R.string.reader_reply)).fetchSemanticsNodes().isNotEmpty()
}
return repo
}
@Test
@@ -111,4 +124,39 @@ class ReaderScreenTest {
composeTestRule.onNodeWithText(seeMore(1)).assertIsDisplayed()
composeTestRule.onNodeWithText("b.pdf").assertDoesNotExist()
}
@Test
fun reader_reply_buildsQuotedReplyDraftAndOpensCompose() {
val repo = renderReader()
composeTestRule.onNodeWithText(string(R.string.reader_reply)).performClick()
composeTestRule.waitUntil(5_000) { openedDraftId != null }
// The reader routes through buildReplyDraft (quotes the original + bakes the signature),
// not a bare compose prefill (#303), and opens compose on the resulting draft.
assertEquals(listOf(messageId to ReplyMode.REPLY), repo.replyDrafts)
assertEquals("draft-$messageId", openedDraftId)
}
@Test
fun reader_forward_viaOverflow_buildsForwardDraft() {
val repo = renderReader()
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_forward)).performClick()
composeTestRule.waitUntil(5_000) { openedDraftId != null }
assertEquals(listOf(messageId to ReplyMode.FORWARD), repo.replyDrafts)
}
@Test
fun reader_replyAll_viaOverflow_buildsReplyAllDraft() {
val repo = renderReader()
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_reply_all)).performClick()
composeTestRule.waitUntil(5_000) { openedDraftId != null }
assertEquals(listOf(messageId to ReplyMode.REPLY_ALL), repo.replyDrafts)
}
}