Repository: thunderbird/thunderbird-android (language: kotlin)

Issue:
Replace MessageList items with new UI components
The new Composable MessageList item UI should replace the XML-based layouts.

These changes should be guarded by a feature flag.


Discussion hints:
nan

Pull request: feat(message-list): change item layout
Resolves #9654

The design is not covering the `MessageListAppearance` options. We need another round of discussion how to handle these.

Test command: ./gradlew :legacy:ui:legacy:testDebugUnitTest --tests "com.fsck.k9.ui.messagelist.MessageListAdapterTest"

Fix patch:
diff --git a/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt b/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt
index c1a641f83b2..abcc807ed3c 100644
--- a/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt
+++ b/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt
@@ -1,5 +1,6 @@
 package app.k9mail.featureflag
 
+import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
 import net.thunderbird.core.featureflag.FeatureFlag
 import net.thunderbird.core.featureflag.FeatureFlagFactory
 import net.thunderbird.core.featureflag.FeatureFlagKey
@@ -16,6 +17,7 @@ class K9FeatureFlagFactory : FeatureFlagFactory {
             FeatureFlag("enable_dropdown_drawer_ui".toFeatureFlagKey(), enabled = true),
             FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = true),
             FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = true),
+            FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
         )
     }
 }
diff --git a/app-k9mail/src/release/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt b/app-k9mail/src/release/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt
index 851dc1ac843..f380c538fa8 100644
--- a/app-k9mail/src/release/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt
+++ b/app-k9mail/src/release/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt
@@ -1,5 +1,6 @@
 package app.k9mail.featureflag
 
+import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
 import net.thunderbird.core.featureflag.FeatureFlag
 import net.thunderbird.core.featureflag.FeatureFlagFactory
 import net.thunderbird.core.featureflag.FeatureFlagKey
@@ -20,6 +21,7 @@ class K9FeatureFlagFactory : FeatureFlagFactory {
             FeatureFlag("enable_dropdown_drawer_ui".toFeatureFlagKey(), enabled = false),
             FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = false),
             FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = false),
+            FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
         )
     }
 }
diff --git a/app-thunderbird/src/beta/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt b/app-thunderbird/src/beta/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
index 6f5a084d4e8..003a330767a 100644
--- a/app-thunderbird/src/beta/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
+++ b/app-thunderbird/src/beta/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
@@ -1,5 +1,6 @@
 package net.thunderbird.android.featureflag
 
+import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
 import net.thunderbird.core.featureflag.FeatureFlag
 import net.thunderbird.core.featureflag.FeatureFlagFactory
 import net.thunderbird.core.featureflag.FeatureFlagKey
@@ -19,6 +20,7 @@ class TbFeatureFlagFactory : FeatureFlagFactory {
             FeatureFlag("enable_dropdown_drawer_ui".toFeatureFlagKey(), enabled = true),
             FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = true),
             FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = false),
+            FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
         )
     }
 }
diff --git a/app-thunderbird/src/daily/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt b/app-thunderbird/src/daily/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
index 47e9cab5c94..4d17004e6b5 100644
--- a/app-thunderbird/src/daily/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
+++ b/app-thunderbird/src/daily/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
@@ -1,5 +1,6 @@
 package net.thunderbird.android.featureflag
 
+import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
 import net.thunderbird.core.featureflag.FeatureFlag
 import net.thunderbird.core.featureflag.FeatureFlagFactory
 import net.thunderbird.core.featureflag.FeatureFlagKey
@@ -19,6 +20,7 @@ class TbFeatureFlagFactory : FeatureFlagFactory {
             FeatureFlag("enable_dropdown_drawer_ui".toFeatureFlagKey(), enabled = true),
             FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = true),
             FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = true),
+            FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
         )
     }
 }
diff --git a/app-thunderbird/src/debug/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt b/app-thunderbird/src/debug/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
index e0591a363df..174a8ee9751 100644
--- a/app-thunderbird/src/debug/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
+++ b/app-thunderbird/src/debug/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
@@ -1,5 +1,6 @@
 package net.thunderbird.android.featureflag
 
+import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
 import net.thunderbird.core.featureflag.FeatureFlag
 import net.thunderbird.core.featureflag.FeatureFlagFactory
 import net.thunderbird.core.featureflag.FeatureFlagKey
@@ -19,6 +20,7 @@ class TbFeatureFlagFactory : FeatureFlagFactory {
             FeatureFlag("enable_dropdown_drawer_ui".toFeatureFlagKey(), enabled = true),
             FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = true),
             FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = true),
+            FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
         )
     }
 }
diff --git a/app-thunderbird/src/release/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt b/app-thunderbird/src/release/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
index f79e5990d8d..86d68154e66 100644
--- a/app-thunderbird/src/release/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
+++ b/app-thunderbird/src/release/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
@@ -1,5 +1,6 @@
 package net.thunderbird.android.featureflag
 
+import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
 import net.thunderbird.core.featureflag.FeatureFlag
 import net.thunderbird.core.featureflag.FeatureFlagFactory
 import net.thunderbird.core.featureflag.FeatureFlagKey
@@ -19,6 +20,7 @@ class TbFeatureFlagFactory : FeatureFlagFactory {
             FeatureFlag("enable_dropdown_drawer_ui".toFeatureFlagKey(), enabled = false),
             FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = false),
             FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = false),
+            FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
         )
     }
 }
diff --git a/legacy/ui/legacy/build.gradle.kts b/legacy/ui/legacy/build.gradle.kts
index 5c44fd33075..6042f39e6e8 100644
--- a/legacy/ui/legacy/build.gradle.kts
+++ b/legacy/ui/legacy/build.gradle.kts
@@ -20,6 +20,7 @@ dependencies {
     implementation(projects.core.ui.theme.api)
     implementation(projects.feature.launcher)
     implementation(projects.core.common)
+    implementation(projects.core.ui.compose.designsystem)
     implementation(projects.feature.navigation.drawer.api)
     implementation(projects.feature.navigation.drawer.dropdown)
     implementation(projects.feature.navigation.drawer.siderail)
diff --git a/legacy/ui/legacy/src/debug/kotlin/com/fsck/k9/ui/messagelist/item/MessageItemContentPreview.kt b/legacy/ui/legacy/src/debug/kotlin/com/fsck/k9/ui/messagelist/item/MessageItemContentPreview.kt
new file mode 100644
index 00000000000..aae82769ac1
--- /dev/null
+++ b/legacy/ui/legacy/src/debug/kotlin/com/fsck/k9/ui/messagelist/item/MessageItemContentPreview.kt
@@ -0,0 +1,99 @@
+package com.fsck.k9.ui.messagelist.item
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark
+import com.fsck.k9.FontSizes
+import com.fsck.k9.UiDensity
+import com.fsck.k9.mail.AuthType
+import com.fsck.k9.mail.ConnectionSecurity
+import com.fsck.k9.mail.ServerSettings
+import com.fsck.k9.ui.messagelist.MessageListAppearance
+import com.fsck.k9.ui.messagelist.MessageListItem
+import net.thunderbird.core.android.account.Identity
+import net.thunderbird.core.android.account.LegacyAccount
+import net.thunderbird.feature.account.AccountIdFactory
+import net.thunderbird.feature.account.storage.profile.AvatarDto
+import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
+import net.thunderbird.feature.account.storage.profile.ProfileDto
+
+@Composable
+@PreviewLightDark
+internal fun MessageItemContentPreview() {
+    PreviewWithThemesLightDark {
+        MessageItemContent(
+            item = fakeMessageListItem,
+            isActive = true,
+            isSelected = false,
+            onClick = {},
+            onLongClick = {},
+            onAvatarClick = {},
+            onFavouriteClick = {},
+            appearance = fakeMessageListAppearance,
+        )
+    }
+}
+
+private val accountId = AccountIdFactory.create()
+
+private val serverSettings = ServerSettings(
+    type = "imap",
+    host = "imap.example.com",
+    port = 993,
+    connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
+    authenticationType = AuthType.PLAIN,
+    username = "username",
+    password = "password",
+    clientCertificateAlias = null,
+)
+private val fakeMessageListItem = MessageListItem(
+    account = LegacyAccount(
+        id = accountId,
+        name = "Name",
+        email = "test@example.com",
+        profile = ProfileDto(
+            id = accountId,
+            name = "Name",
+            color = 0xFF0000FF.toInt(),
+            avatar = AvatarDto(
+                avatarType = AvatarTypeDto.MONOGRAM,
+                avatarMonogram = "AB",
+                avatarImageUri = null,
+                avatarIconName = null,
+            ),
+        ),
+        incomingServerSettings = serverSettings,
+        outgoingServerSettings = serverSettings,
+        identities = listOf(Identity()),
+    ),
+    subject = "Subject",
+    threadCount = 0,
+    messageDate = 1234456789L,
+    internalDate = 1234456789L,
+    displayName = "Sender Name",
+    displayAddress = null,
+    previewText = "This is the preview text.",
+    isMessageEncrypted = false,
+    isRead = false,
+    isStarred = false,
+    isAnswered = false,
+    isForwarded = false,
+    hasAttachments = false,
+    uniqueId = 42L,
+    folderId = 123L,
+    messageUid = "654321",
+    databaseId = 1L,
+    threadRoot = 1L,
+)
+
+private val fakeMessageListAppearance = MessageListAppearance(
+    fontSizes = FontSizes(),
+    previewLines = 2,
+    stars = true,
+    senderAboveSubject = false,
+    showContactPicture = true,
+    showingThreadedList = false,
+    backGroundAsReadIndicator = false,
+    showAccountIndicator = true,
+    density = UiDensity.Default,
+)
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt
index 838d6286d6d..f059eec8eb7 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt
@@ -141,8 +141,8 @@ open class MessageList :
     private var messageListWasDisplayed = false
     private var viewSwitcher: ViewSwitcher? = null
 
-    private val isShowAccountChip: Boolean
-        get() = messageListFragment?.isShowAccountChip ?: true
+    private val isShowAccountIndicator: Boolean
+        get() = messageListFragment?.isShowAccountIndicator ?: true
 
     public override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -1106,7 +1106,7 @@ open class MessageList :
         } else {
             val fragment = MessageViewContainerFragment.newInstance(
                 reference = messageReference,
-                showAccountChip = isShowAccountChip,
+                isShowAccountIndicator = isShowAccountIndicator,
             )
             supportFragmentManager.commitNow {
                 replace(R.id.message_view_container, fragment, FRAGMENT_TAG_MESSAGE_VIEW_CONTAINER)
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt
index 4bc9b866037..c1221d8bddf 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt
@@ -17,7 +17,9 @@ import app.k9mail.feature.launcher.FeatureLauncherTarget
 import app.k9mail.legacy.message.controller.MessageReference
 import com.fsck.k9.contacts.ContactPictureLoader
 import com.fsck.k9.ui.helper.RelativeDateTimeFormatter
+import com.fsck.k9.ui.messagelist.MessageListFeatureFlags.UseComposeForMessageListItems
 import com.fsck.k9.ui.messagelist.item.BannerInlineListInAppNotificationViewHolder
+import com.fsck.k9.ui.messagelist.item.ComposableMessageViewHolder
 import com.fsck.k9.ui.messagelist.item.FooterViewHolder
 import com.fsck.k9.ui.messagelist.item.MessageListViewHolder
 import com.fsck.k9.ui.messagelist.item.MessageViewHolder
@@ -25,6 +27,7 @@ import com.fsck.k9.ui.messagelist.item.MessageViewHolderColors
 import net.thunderbird.core.featureflag.FeatureFlagKey
 import net.thunderbird.core.featureflag.FeatureFlagProvider
 import net.thunderbird.core.featureflag.FeatureFlagResult
+import net.thunderbird.core.ui.theme.api.FeatureThemeProvider
 import net.thunderbird.feature.notification.api.ui.action.NotificationAction
 
 private const val FOOTER_ID = 1L
@@ -42,6 +45,7 @@ class MessageListAdapter internal constructor(
     private val listItemListener: MessageListItemActionListener,
     private val appearance: MessageListAppearance,
     private val relativeDateTimeFormatter: RelativeDateTimeFormatter,
+    private val themeProvider: FeatureThemeProvider,
     private val featureFlagProvider: FeatureFlagProvider,
 ) : RecyclerView.Adapter<MessageListViewHolder>() {
 
@@ -227,7 +231,15 @@ class MessageListAdapter internal constructor(
 
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageListViewHolder {
         return when (viewType) {
-            TYPE_MESSAGE -> createMessageViewHolder(parent)
+            TYPE_MESSAGE -> {
+                val result = featureFlagProvider.provide(UseComposeForMessageListItems)
+                if (result.isEnabled()) {
+                    createComposableMessageViewHolder(parent)
+                } else {
+                    createMessageViewHolder(parent)
+                }
+            }
+
             TYPE_FOOTER -> FooterViewHolder.create(layoutInflater, parent, footerClickListener)
             TYPE_IN_APP_NOTIFICATION_BANNER_INLINE_LIST if isInAppNotificationEnabled ->
                 BannerInlineListInAppNotificationViewHolder(
@@ -259,7 +271,7 @@ class MessageListAdapter internal constructor(
         }
     }
 
-    private fun createMessageViewHolder(parent: ViewGroup?): MessageViewHolder =
+    private fun createMessageViewHolder(parent: ViewGroup): MessageViewHolder =
         MessageViewHolder.create(
             layoutInflater = layoutInflater,
             parent = parent,
@@ -275,6 +287,17 @@ class MessageListAdapter internal constructor(
             starClickListener = starClickListener,
         )
 
+    private fun createComposableMessageViewHolder(parent: ViewGroup): MessageListViewHolder =
+        ComposableMessageViewHolder.create(
+            context = parent.context,
+            themeProvider = themeProvider,
+            onClick = { listItemListener.onMessageClicked(it) },
+            onLongClick = { listItemListener.onToggleMessageSelection(it) },
+            onFavouriteClick = { listItemListener.onToggleMessageFlag(it) },
+            onAvatarClick = { listItemListener.onToggleMessageSelection(it) },
+            appearance = appearance,
+        )
+
     override fun onBindViewHolder(holder: MessageListViewHolder, position: Int) {
         when (val viewType = getItemViewType(position)) {
             TYPE_IN_APP_NOTIFICATION_BANNER_INLINE_LIST if isInAppNotificationEnabled ->
@@ -282,12 +305,22 @@ class MessageListAdapter internal constructor(
 
             TYPE_MESSAGE -> {
                 val messageListItem = getItem(position)
-                val messageViewHolder = holder as MessageViewHolder
-                messageViewHolder.bind(
-                    messageListItem = messageListItem,
-                    isActive = isActiveMessage(messageListItem),
-                    isSelected = isSelected(messageListItem),
-                )
+                val result = featureFlagProvider.provide(UseComposeForMessageListItems)
+                if (result.isEnabled()) {
+                    val messageViewHolder = holder as ComposableMessageViewHolder
+                    messageViewHolder.bind(
+                        item = messageListItem,
+                        isActive = isActiveMessage(messageListItem),
+                        isSelected = isSelected(messageListItem),
+                    )
+                } else {
+                    val messageViewHolder = holder as MessageViewHolder
+                    messageViewHolder.bind(
+                        messageListItem = messageListItem,
+                        isActive = isActiveMessage(messageListItem),
+                        isSelected = isSelected(messageListItem),
+                    )
+                }
             }
 
             TYPE_FOOTER -> {
@@ -368,8 +401,13 @@ class MessageListAdapter internal constructor(
     }
 
     private fun getItemFromView(view: View): MessageListItem? {
-        val messageViewHolder = view.tag as MessageViewHolder
-        return getItemById(messageViewHolder.uniqueId)
+        if (featureFlagProvider.provide(UseComposeForMessageListItems).isEnabled()) {
+            val messageViewHolder = view.tag as ComposableMessageViewHolder
+            return getItemById(messageViewHolder.uniqueId)
+        } else {
+            val messageViewHolder = view.tag as MessageViewHolder
+            return getItemById(messageViewHolder.uniqueId)
+        }
     }
 }
 
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAppearance.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAppearance.kt
index 9958682d09c..33ade74c48c 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAppearance.kt
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAppearance.kt
@@ -11,6 +11,9 @@ data class MessageListAppearance(
     val showContactPicture: Boolean,
     val showingThreadedList: Boolean,
     val backGroundAsReadIndicator: Boolean,
-    val showAccountChip: Boolean,
+    /**
+     * Whether to show an account color indicator on the left side of the message item.
+     */
+    val showAccountIndicator: Boolean,
     val density: UiDensity,
 )
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFeatureFlags.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFeatureFlags.kt
new file mode 100644
index 00000000000..c66c5fc9165
--- /dev/null
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFeatureFlags.kt
@@ -0,0 +1,8 @@
+package com.fsck.k9.ui.messagelist
+
+import net.thunderbird.core.featureflag.FeatureFlagKey
+
+object MessageListFeatureFlags {
+
+    val UseComposeForMessageListItems = FeatureFlagKey("use_compose_for_message_list_items")
+}
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt
index ebee1c575c9..5a89a91c8c4 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt
@@ -232,7 +232,7 @@ class MessageListFragment :
             maybeHideFloatingActionButton()
         }
 
-    val isShowAccountChip: Boolean
+    val isShowAccountIndicator: Boolean
         get() = isUnifiedFolders || !isSingleAccountMode
 
     override fun onAttach(context: Context) {
@@ -344,6 +344,7 @@ class MessageListFragment :
             listItemListener = this,
             appearance = messageListAppearance,
             relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), clock),
+            themeProvider = featureThemeProvider,
             featureFlagProvider = featureFlagProvider,
         ).apply {
             activeMessage = this@MessageListFragment.activeMessage
@@ -784,7 +785,7 @@ class MessageListFragment :
             showingThreadedList = showingThreadedList,
             backGroundAsReadIndicator = generalSettingsManager
                 .getConfig().display.visualSettings.isUseBackgroundAsUnreadIndicator,
-            showAccountChip = isShowAccountChip,
+            showAccountIndicator = isShowAccountIndicator,
             density = K9.messageListDensity,
         )
 
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt
index cb388d16930..4a3f14eae32 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt
@@ -15,6 +15,7 @@ import androidx.recyclerview.widget.RecyclerView
 import androidx.recyclerview.widget.RecyclerView.ViewHolder
 import app.k9mail.ui.utils.itemtouchhelper.ItemTouchHelper
 import com.fsck.k9.ui.R
+import com.fsck.k9.ui.messagelist.item.ComposableMessageViewHolder
 import com.fsck.k9.ui.messagelist.item.MessageViewHolder
 import com.google.android.material.color.ColorRoles
 import com.google.android.material.textview.MaterialTextView
@@ -81,9 +82,7 @@ class MessageListSwipeCallback(
         }
 
     override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: ViewHolder): Int {
-        if (viewHolder !is MessageViewHolder) return 0
-
-        val item = adapter.getItemById(viewHolder.uniqueId) ?: return 0
+        val item = viewHolder.messageListItem ?: return 0
         val (swipeLeftAction, swipeRightAction) = item.swipeActions
 
         var swipeFlags = 0
@@ -178,8 +177,7 @@ class MessageListSwipeCallback(
 
         if (dX != 0F) {
             canvas.withTranslation(x = view.left.toFloat(), y = view.top.toFloat()) {
-                val holder = viewHolder as MessageViewHolder
-                val item = adapter.getItemById(holder.uniqueId) ?: return@withTranslation
+                val item = viewHolder.messageListItem ?: return@withTranslation
                 if (isCurrentlyActive || !success) {
                     drawLayout(dX, viewWidth, viewHeight, item)
                 } else {
@@ -377,7 +375,11 @@ class MessageListSwipeCallback(
     }
 
     private val ViewHolder.messageListItem: MessageListItem?
-        get() = (this as? MessageViewHolder)?.uniqueId?.let { adapter.getItemById(it) }
+        get() = when (this) {
+            is MessageViewHolder -> adapter.getItemById(uniqueId)
+            is ComposableMessageViewHolder -> adapter.getItemById(uniqueId)
+            else -> null
+        }
 }
 
 fun interface SwipeActionSupportProvider {
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/ComposableMessageViewHolder.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/ComposableMessageViewHolder.kt
new file mode 100644
index 00000000000..13d92b69dfb
--- /dev/null
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/ComposableMessageViewHolder.kt
@@ -0,0 +1,71 @@
+package com.fsck.k9.ui.messagelist.item
+
+import android.content.Context
+import androidx.compose.ui.platform.ComposeView
+import com.fsck.k9.ui.messagelist.MessageListAppearance
+import com.fsck.k9.ui.messagelist.MessageListItem
+import net.thunderbird.core.ui.theme.api.FeatureThemeProvider
+
+/**
+ * A composable view holder for message list items.
+ */
+class ComposableMessageViewHolder(
+    private val composeView: ComposeView,
+    private val themeProvider: FeatureThemeProvider,
+    private val onClick: (MessageListItem) -> Unit,
+    private val onLongClick: (MessageListItem) -> Unit,
+    private val onAvatarClick: (MessageListItem) -> Unit,
+    private val onFavouriteClick: (MessageListItem) -> Unit,
+    private val appearance: MessageListAppearance,
+) : MessageListViewHolder(composeView) {
+
+    var uniqueId: Long = -1L
+
+    fun bind(item: MessageListItem, isActive: Boolean, isSelected: Boolean) {
+        uniqueId = item.uniqueId
+
+        composeView.setContent {
+            themeProvider.WithTheme {
+                MessageItemContent(
+                    item = item,
+                    isActive = isActive,
+                    isSelected = isSelected,
+                    onClick = { onClick(item) },
+                    onLongClick = { onLongClick(item) },
+                    onAvatarClick = { onAvatarClick(item) },
+                    onFavouriteClick = { onFavouriteClick(item) },
+                    appearance = appearance,
+                )
+            }
+        }
+    }
+
+    companion object {
+
+        fun create(
+            context: Context,
+            themeProvider: FeatureThemeProvider,
+            onClick: (MessageListItem) -> Unit,
+            onLongClick: (MessageListItem) -> Unit,
+            onFavouriteClick: (MessageListItem) -> Unit,
+            onAvatarClick: (MessageListItem) -> Unit,
+            appearance: MessageListAppearance,
+        ): ComposableMessageViewHolder {
+            val composeView = ComposeView(context)
+
+            val holder = ComposableMessageViewHolder(
+                composeView = composeView,
+                themeProvider = themeProvider,
+                onClick = onClick,
+                onLongClick = onLongClick,
+                onAvatarClick = onAvatarClick,
+                onFavouriteClick = onFavouriteClick,
+                appearance = appearance,
+            )
+
+            composeView.tag = holder
+
+            return holder
+        }
+    }
+}
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt
new file mode 100644
index 00000000000..3ff073a960b
--- /dev/null
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt
@@ -0,0 +1,86 @@
+package com.fsck.k9.ui.messagelist.item
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.fsck.k9.ui.messagelist.MessageListAppearance
+import com.fsck.k9.ui.messagelist.MessageListItem
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import net.thunderbird.core.ui.compose.designsystem.organism.message.ActiveMessageItem
+import net.thunderbird.core.ui.compose.designsystem.organism.message.ReadMessageItem
+import net.thunderbird.core.ui.compose.designsystem.organism.message.UnreadMessageItem
+
+@Suppress("LongParameterList")
+@OptIn(ExperimentalTime::class)
+@Composable
+internal fun MessageItemContent(
+    item: MessageListItem,
+    isActive: Boolean,
+    isSelected: Boolean,
+    onClick: () -> Unit,
+    onLongClick: () -> Unit,
+    onAvatarClick: () -> Unit,
+    onFavouriteClick: (Boolean) -> Unit,
+    appearance: MessageListAppearance,
+) {
+    val receivedAt = remember(item.messageDate) {
+        Instant.fromEpochMilliseconds(item.messageDate)
+            .toLocalDateTime(TimeZone.currentSystemDefault())
+    }
+
+    when {
+        isActive -> ActiveMessageItem(
+            sender = "${item.displayName}",
+            subject = item.subject ?: "n/a",
+            preview = item.previewText,
+            receivedAt = receivedAt,
+            avatar = {},
+            onClick = onClick,
+            onLongClick = onLongClick,
+            onLeadingClick = onAvatarClick,
+            onFavouriteChange = onFavouriteClick,
+            favourite = item.isStarred,
+            selected = isSelected,
+            maxPreviewLines = appearance.previewLines,
+            threadCount = item.threadCount,
+            hasAttachments = item.hasAttachments,
+            swapSenderWithSubject = !appearance.senderAboveSubject,
+        )
+        item.isRead -> ReadMessageItem(
+            sender = "${item.displayName}",
+            subject = item.subject ?: "n/a",
+            preview = item.previewText,
+            receivedAt = receivedAt,
+            avatar = {},
+            onClick = onClick,
+            onLongClick = onLongClick,
+            onLeadingClick = onAvatarClick,
+            onFavouriteChange = onFavouriteClick,
+            favourite = item.isStarred,
+            selected = isSelected,
+            maxPreviewLines = appearance.previewLines,
+            threadCount = item.threadCount,
+            hasAttachments = item.hasAttachments,
+            swapSenderWithSubject = !appearance.senderAboveSubject,
+        )
+        else -> UnreadMessageItem(
+            sender = "${item.displayName}",
+            subject = item.subject ?: "n/a",
+            preview = item.previewText,
+            receivedAt = receivedAt,
+            avatar = {},
+            onClick = onClick,
+            onLongClick = onLongClick,
+            onLeadingClick = onAvatarClick,
+            onFavouriteChange = onFavouriteClick,
+            favourite = item.isStarred,
+            selected = isSelected,
+            maxPreviewLines = appearance.previewLines,
+            threadCount = item.threadCount,
+            hasAttachments = item.hasAttachments,
+            swapSenderWithSubject = !appearance.senderAboveSubject,
+        )
+    }
+}
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageViewHolder.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageViewHolder.kt
index 1ab2c3a517b..7398575f283 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageViewHolder.kt
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageViewHolder.kt
@@ -89,10 +89,10 @@ class MessageViewHolder(
                 threadCount = displayThreadCount,
             )
 
-            if (appearance.showAccountChip) {
-                val accountChipDrawable = chipView.drawable.mutate()
-                DrawableCompat.setTint(accountChipDrawable, account.profile.color)
-                chipView.setImageDrawable(accountChipDrawable)
+            if (appearance.showAccountIndicator) {
+                val accountIndicatorDrawable = chipView.drawable.mutate()
+                DrawableCompat.setTint(accountIndicatorDrawable, account.profile.color)
+                chipView.setImageDrawable(accountIndicatorDrawable)
             }
 
             if (appearance.stars) {
@@ -315,7 +315,7 @@ class MessageViewHolder(
                 holder.contactPictureView.isVisible = false
             }
 
-            holder.chipView.isVisible = appearance.showAccountChip
+            holder.chipView.isVisible = appearance.showAccountIndicator
 
             // 1 preview line is needed even if it is set to 0, because subject is part of the same text view
             holder.previewView.maxLines = max(appearance.previewLines, 1)
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt
index 18b434ee127..3ab27108564 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt
@@ -57,7 +57,7 @@ class MessageTopView(
     private var isShowingProgress = false
     private var showPicturesButtonClicked = false
 
-    private var showAccountChip = false
+    private var showAccountIndicator = false
 
     private var messageCryptoPresenter: MessageCryptoPresenter? = null
 
@@ -83,8 +83,8 @@ class MessageTopView(
         hideHeaderView()
     }
 
-    fun setShowAccountChip(showAccountChip: Boolean) {
-        this.showAccountChip = showAccountChip
+    fun setShowAccountIndicator(showAccountIndicator: Boolean) {
+        this.showAccountIndicator = showAccountIndicator
     }
 
     private fun setShowPicturesButtonListener() {
@@ -197,7 +197,7 @@ class MessageTopView(
     }
 
     fun setHeaders(message: Message?, account: LegacyAccountDto?, showStar: Boolean) {
-        messageHeaderView.populate(message, account, showStar, showAccountChip)
+        messageHeaderView.populate(message, account, showStar, showAccountIndicator)
         messageHeaderView.visibility = VISIBLE
     }
 
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt
index 0673ad6de7b..d5fec98afbc 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt
@@ -30,7 +30,7 @@ class MessageViewContainerFragment : Fragment() {
             setMenuVisibility(value)
         }
 
-    private var showAccountChip: Boolean = true
+    private var showAccountIndicator: Boolean = true
 
     lateinit var messageReference: MessageReference
         private set
@@ -68,9 +68,9 @@ class MessageViewContainerFragment : Fragment() {
                 ?: error("Missing state $STATE_MESSAGE_REFERENCE")
         }
 
-        showAccountChip = arguments?.getBoolean(ARG_SHOW_ACCOUNT_CHIP) ?: showAccountChip
+        showAccountIndicator = arguments?.getBoolean(ARG_SHOW_ACCOUNT_INDICATOR) ?: showAccountIndicator
 
-        adapter = MessageViewContainerAdapter(this, showAccountChip)
+        adapter = MessageViewContainerAdapter(this, showAccountIndicator)
     }
 
     override fun onAttach(context: Context) {
@@ -230,7 +230,7 @@ class MessageViewContainerFragment : Fragment() {
 
     private class MessageViewContainerAdapter(
         fragment: Fragment,
-        private val showAccountChip: Boolean,
+        private val showAccountIndicator: Boolean,
     ) : FragmentStateAdapter(fragment) {
 
         var messageList: List<MessageListItem> = emptyList()
@@ -260,7 +260,7 @@ class MessageViewContainerFragment : Fragment() {
             check(position in messageList.indices)
 
             val messageReference = messageList[position].messageReference
-            return MessageViewFragment.newInstance(messageReference, showAccountChip)
+            return MessageViewFragment.newInstance(messageReference, showAccountIndicator)
         }
 
         fun getMessageReference(position: Int): MessageReference? {
@@ -304,17 +304,17 @@ class MessageViewContainerFragment : Fragment() {
         private const val VIEW_PAGER_SWIPE_VELOCITY_THRESHOLD = 0.8f
 
         private const val ARG_REFERENCE = "reference"
-        private const val ARG_SHOW_ACCOUNT_CHIP = "showAccountChip"
+        private const val ARG_SHOW_ACCOUNT_INDICATOR = "showAccountIndicator"
 
         private const val STATE_MESSAGE_REFERENCE = "messageReference"
 
         fun newInstance(
             reference: MessageReference,
-            showAccountChip: Boolean,
+            isShowAccountIndicator: Boolean,
         ): MessageViewContainerFragment {
             return MessageViewContainerFragment().withArguments(
                 ARG_REFERENCE to reference.toIdentityString(),
-                ARG_SHOW_ACCOUNT_CHIP to showAccountChip,
+                ARG_SHOW_ACCOUNT_INDICATOR to isShowAccountIndicator,
             )
         }
     }
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
index dd037df751b..66072c2e281 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
@@ -107,7 +107,7 @@ class MessageViewFragment :
 
     private lateinit var account: LegacyAccountDto
     lateinit var messageReference: MessageReference
-    private var showAccountChip: Boolean = true
+    private var showAccountIndicator: Boolean = true
 
     private var currentAttachmentViewInfo: AttachmentViewInfo? = null
     private var isDeleteMenuItemDisabled: Boolean = false
@@ -140,8 +140,8 @@ class MessageViewFragment :
         messageReference = MessageReference.parse(arguments?.getString(ARG_REFERENCE))
             ?: error("Invalid argument '$ARG_REFERENCE'")
 
-        showAccountChip = arguments?.getBoolean(ARG_SHOW_ACCOUNT_CHIP)
-            ?: error("Missing argument: '$ARG_SHOW_ACCOUNT_CHIP'")
+        showAccountIndicator = arguments?.getBoolean(ARG_SHOW_ACCOUNT_INDICATOR)
+            ?: error("Missing argument: '$ARG_SHOW_ACCOUNT_INDICATOR'")
 
         if (savedInstanceState != null) {
             wasMessageMarkedAsOpened = savedInstanceState.getBoolean(STATE_WAS_MESSAGE_MARKED_AS_OPENED)
@@ -173,7 +173,7 @@ class MessageViewFragment :
     }
 
     private fun initializeMessageTopView(messageTopView: MessageTopView) {
-        messageTopView.setShowAccountChip(showAccountChip)
+        messageTopView.setShowAccountIndicator(showAccountIndicator)
 
         messageTopView.setAttachmentCallback(this)
         messageTopView.setMessageCryptoPresenter(messageCryptoPresenter)
@@ -995,15 +995,15 @@ class MessageViewFragment :
         const val PROGRESS_THRESHOLD_MILLIS = 500 * 1000
 
         private const val ARG_REFERENCE = "reference"
-        private const val ARG_SHOW_ACCOUNT_CHIP = "showAccountChip"
+        private const val ARG_SHOW_ACCOUNT_INDICATOR = "showAccountIndicator"
 
         private const val STATE_WAS_MESSAGE_MARKED_AS_OPENED = "wasMessageMarkedAsOpened"
         private const val STATE_IS_ACTIVE = "isActive"
 
-        fun newInstance(reference: MessageReference, showAccountChip: Boolean): MessageViewFragment {
+        fun newInstance(reference: MessageReference, showAccountIndicator: Boolean): MessageViewFragment {
             return MessageViewFragment().withArguments(
                 ARG_REFERENCE to reference.toIdentityString(),
-                ARG_SHOW_ACCOUNT_CHIP to showAccountChip,
+                ARG_SHOW_ACCOUNT_INDICATOR to showAccountIndicator,
             )
         }
     }
diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java
index c2330fee9dc..666e0e6915f 100644
--- a/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java
+++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java
@@ -195,8 +195,8 @@ public void setOnFlagListener(OnClickListener listener) {
         starView.setOnClickListener(listener);
     }
 
-    public void populate(final Message message, final LegacyAccountDto account, boolean showStar, boolean showAccountChip) {
-        if (showAccountChip) {
+    public void populate(final Message message, final LegacyAccountDto account, boolean showStar, boolean showAccountIndicator) {
+        if (showAccountIndicator) {
             accountNameView.setVisibility(View.VISIBLE);
             accountNameView.setText(account.getDisplayName());
             accountNameView.setChipBackgroundColor(ColorStateList.valueOf(account.getChipColor()));


Test patch:
diff --git a/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt b/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt
index ba843fdcfcb..97da1876546 100644
--- a/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt
+++ b/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt
@@ -8,6 +8,7 @@ import android.view.LayoutInflater
 import android.view.View
 import android.widget.LinearLayout
 import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.Composable
 import androidx.core.view.isGone
 import androidx.core.view.isVisible
 import assertk.Assert
@@ -31,8 +32,11 @@ import kotlin.time.ExperimentalTime
 import net.thunderbird.core.android.account.Identity
 import net.thunderbird.core.android.account.LegacyAccount
 import net.thunderbird.core.android.testing.RobolectricTest
+import net.thunderbird.core.featureflag.FeatureFlagKey
+import net.thunderbird.core.featureflag.FeatureFlagProvider
 import net.thunderbird.core.featureflag.FeatureFlagResult
 import net.thunderbird.core.testing.TestClock
+import net.thunderbird.core.ui.theme.api.FeatureThemeProvider
 import net.thunderbird.feature.account.AccountIdFactory
 import net.thunderbird.feature.account.storage.profile.AvatarDto
 import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
@@ -54,21 +58,21 @@ class MessageListAdapterTest : RobolectricTest() {
     val listItemListener: MessageListItemActionListener = mock()
 
     @Test
-    fun withShowAccountChip_shouldShowAccountChip() {
-        val adapter = createAdapter(showAccountChip = true)
+    fun withShowAccountIndicator_shouldShowAccountIndicator() {
+        val adapter = createAdapter(showAccountIndicator = true)
 
         val view = adapter.createAndBindView()
 
-        assertThat(view.accountChipView).isVisible()
+        assertThat(view.accountIndicatorView).isVisible()
     }
 
     @Test
-    fun withoutShowAccountChip_shouldHideAccountChip() {
-        val adapter = createAdapter(showAccountChip = false)
+    fun withoutShowAccountIndicator_shouldHideAccountIndicator() {
+        val adapter = createAdapter(showAccountIndicator = false)
 
         val view = adapter.createAndBindView()
 
-        assertThat(view.accountChipView).isGone()
+        assertThat(view.accountIndicatorView).isGone()
     }
 
     @Test
@@ -408,7 +412,7 @@ class MessageListAdapterTest : RobolectricTest() {
         showContactPicture: Boolean = true,
         showingThreadedList: Boolean = true,
         backGroundAsReadIndicator: Boolean = false,
-        showAccountChip: Boolean = false,
+        showAccountIndicator: Boolean = false,
         density: UiDensity = UiDensity.Default,
     ): MessageListAdapter {
         val appearance = MessageListAppearance(
@@ -419,7 +423,7 @@ class MessageListAdapterTest : RobolectricTest() {
             showContactPicture,
             showingThreadedList,
             backGroundAsReadIndicator,
-            showAccountChip,
+            showAccountIndicator,
             density,
         )
 
@@ -432,7 +436,8 @@ class MessageListAdapterTest : RobolectricTest() {
             listItemListener = listItemListener,
             appearance = appearance,
             relativeDateTimeFormatter = RelativeDateTimeFormatter(context, TestClock()),
-            featureFlagProvider = { FeatureFlagResult.Disabled },
+            themeProvider = FakeThemeProvider(),
+            featureFlagProvider = FakeFeatureFlagProvider(),
         )
     }
 
@@ -534,7 +539,7 @@ class MessageListAdapterTest : RobolectricTest() {
 
     fun secondLine(senderOrSubject: String, preview: String) = "$senderOrSubject – $preview"
 
-    val View.accountChipView: View get() = findViewById(R.id.account_color_chip)
+    val View.accountIndicatorView: View get() = findViewById(R.id.account_color_chip)
     val View.starView: View get() = findViewById(R.id.star)
     val View.contactPictureContainerView: View get() = findViewById(R.id.contact_picture_click_area)
     val View.threadCountView: MaterialTextView get() = findViewById(R.id.thread_count)
@@ -578,4 +583,24 @@ class MessageListAdapterTest : RobolectricTest() {
 
     private val MaterialTextView.textString: String
         get() = text.toString()
+
+    private class FakeThemeProvider : FeatureThemeProvider {
+        @Composable
+        override fun WithTheme(content: @Composable (() -> Unit)) {
+            content()
+        }
+
+        @Composable
+        override fun WithTheme(
+            darkTheme: Boolean,
+            content: @Composable (() -> Unit),
+        ) {
+            content()
+        }
+    }
+
+    private class FakeFeatureFlagProvider : FeatureFlagProvider {
+        // Disabled as the test is primarily concerned with the XML based UI
+        override fun provide(key: FeatureFlagKey): FeatureFlagResult = FeatureFlagResult.Disabled
+    }
 }