Merge remote-tracking branch 'origin/main' into feat-fetch-all-retention

This commit is contained in:
2026-07-01 22:46:08 -05:00
2 changed files with 73 additions and 26 deletions
@@ -54,8 +54,8 @@ class MailboxScreenTest {
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
)
private fun message(uid: String, subject: String, bodyFetched: Boolean = false) = Message(
id = "imap:a:INBOX:$uid",
private fun message(uid: String, subject: String, bodyFetched: Boolean = false, folder: String = "INBOX") = Message(
id = "imap:a:$folder:$uid",
accountId = "imap:a",
sender = "Sender $uid",
senderEmail = "s$uid@example.org",
@@ -66,12 +66,12 @@ class MailboxScreenTest {
timestampMillis = 1_000L,
isRead = true,
isStarred = false,
folder = "INBOX",
folder = folder,
inInbox = true,
bodyFetched = bodyFetched,
)
private fun setContent(repo: FakeMailRepository) {
private fun setContent(repo: FakeMailRepository): MailboxViewModel {
val viewModel = MailboxViewModel(
repo,
FakeAccountRepository(accounts = listOf(account)),
@@ -92,6 +92,7 @@ class MailboxScreenTest {
)
}
}
return viewModel
}
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
@@ -132,11 +133,58 @@ class MailboxScreenTest {
composeTestRule.onNodeWithText("Second").performClick()
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_archive)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_select_all)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist()
}
@Test
fun archiveIcon_isDirect_andArchivesTheSelection() {
val repo = FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second")))
setContent(repo)
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithText("Second").performClick()
// A direct icon button — no trip through the overflow menu.
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).performClick()
composeTestRule.waitUntil(5_000) { repo.archivedIds.isNotEmpty() }
assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), repo.archivedIds.first().toSet())
}
@Test
fun spamIcon_isDirect_andConfirmsBeforeReporting() {
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
setContent(repo)
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).performClick()
composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
composeTestRule.waitUntil(5_000) { repo.spammedIds.isNotEmpty() }
assertEquals(listOf("imap:a:INBOX:1"), repo.spammedIds.first())
}
@Test
fun archiveIcon_hides_whileViewingTheArchiveFolder() {
val repo = FakeMailRepository(
messages = listOf(message("1", "Old news", folder = "Archive")),
folders = listOf(Folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE, selectable = true)),
)
val viewModel = setContent(repo)
viewModel.selectFolder("imap:a", "Archive")
waitForText("Old news")
composeTestRule.onNodeWithText("Old news").performTouchInput { longClick() }
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).assertDoesNotExist()
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).assertIsDisplayed()
}
@Test
fun delete_confirmsMoveToTrash_thenTrashesViaRepository() {
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
@@ -31,11 +31,13 @@ import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
@@ -530,9 +532,12 @@ private fun SelectedAvatar() {
}
/**
* The contextual action bar shown while messages are selected. Archive/Delete are the common actions;
* the overflow holds the rest. Reply/Reply All/Forward appear only for a single selected message, and
* Archive/Spam are hidden while already viewing that role's folder.
* The contextual action bar shown while messages are selected. The common actions — Archive, Spam,
* Delete — are direct icon buttons (matching the reader's icons-not-menus app bar); Archive/Spam are
* hidden while already viewing that role's folder. The overflow keeps only the long tail: Move (no
* usable glyph in material-icons-core) and Select all, plus Reply/Reply All/Forward for a single
* selected message. At most four 48dp actions plus the close button fit a 320dp-wide bar; the
* count title just truncates earlier on such screens.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -558,6 +563,18 @@ private fun SelectionTopBar(
}
},
actions = {
if (folderRole != FolderRole.ARCHIVE) {
IconButton(onClick = onArchive) {
// material-icons-core ships no archive glyph; the checkmark leans on the
// "done with it = archive it" mail idiom (Google Inbox's sweep).
Icon(Icons.Filled.Done, contentDescription = stringResource(R.string.action_archive))
}
}
if (folderRole != FolderRole.SPAM) {
IconButton(onClick = onSpam) {
Icon(Icons.Filled.Warning, contentDescription = stringResource(R.string.action_spam))
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
}
@@ -566,24 +583,6 @@ private fun SelectionTopBar(
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
if (folderRole != FolderRole.ARCHIVE) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_archive)) },
onClick = {
expanded = false
onArchive()
},
)
}
if (folderRole != FolderRole.SPAM) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_spam)) },
onClick = {
expanded = false
onSpam()
},
)
}
if (canMove) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_move)) },