From 1d796e3c4116bcfad80690699e673eed40996a97 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 22:13:05 -0500 Subject: [PATCH] feat(message): move message actions from dropdown to top-bar icons The multi-select contextual action bar buried Archive, Spam, Move, Select all, and the single-selection Reply/Reply All/Forward behind one MoreVert dropdown; only Close and Delete were direct. Promote the common actions to direct IconButtons, matching the reader app bar's icons-not-menus pattern: Archive (Done glyph - material-icons-core has no archive icon, so this leans on the "done = archive" mail idiom), Spam (Warning), and Delete, each with a contentDescription for accessibility. The overflow keeps only the long tail: Move (no usable core glyph, per the ticket it stays text-labeled), Select all, and the single-selection reply actions. All conditional visibility is preserved: Archive/Spam still hide while viewing their own role folder, Move still requires a single-account selection, and the reply actions still require exactly one selected message. Four 48dp actions plus Close still fit a 320dp-wide bar; the count title just truncates earlier. UI tests: the direct Archive icon archives without opening the overflow, the direct Spam icon still confirms before reporting, the Archive icon hides inside the archive folder, and the overflow test now keys on Select all instead of the promoted Archive. Closes #87 Co-Authored-By: Claude Fable 5 --- .../libremail/ui/mailbox/MailboxScreenTest.kt | 58 +++++++++++++++++-- .../org/libremail/ui/mailbox/MailboxScreen.kt | 41 +++++++------ 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt index 0be3688..fafb851 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt @@ -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"))) diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index 5c87165..aaebd11 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -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)) },