Repository: wordpress-mobile/WordPress-Android (language: kotlin)

Issue:
[Reader] Implement horizontal posts list logic (loaded state)
Depends on https://github.com/wordpress-mobile/WordPress-Android/issues/20623 and https://github.com/wordpress-mobile/WordPress-Android/issues/20588

Connect fetched tags data to the list UI component.


Discussion hints:
Thanks for reporting! 👍 <!-- generated_by_dangermattic -->


Pull request: [Reader] Connect fetch tags logic with compose UI
Fixes #20624 

https://github.com/wordpress-mobile/WordPress-Android/assets/14964993/b5a79353-49f6-47b8-8366-5836f3388106

-----

## To Test:

- Install JP and sign in.
- Open "Reader".
- 🔍  Select "Tags" feed: loading, loaded, error and empty states should be displayed correctly ([actions not working yet](https://github.com/wordpress-mobile/WordPress-Android/issues/20670)).

-----

## Regression Notes

1. Potential unintended areas of impact

    - None

2. What I did to test those areas of impact (or what existing automated tests I relied on)

    - Manual testing

3. What automated tests I added (or what prevented me from doing so)

    - Updated `ReaderTagsFeedViewModelTest.kt` and implemented `ReaderTagsFeedUiStateMapperTest.kt`.

-----

## PR Submission Checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

-----

## Testing Checklist (strike-out the not-applying and unnecessary ones):

- [ ] WordPress.com sites and self-hosted Jetpack sites.
- [ ] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [ ] Talkback.
- [ ] Languages with large words or with letters/accents not frequently used in English.
- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [ ] Large and small screen sizes. (Tablet and smaller phones)
- [ ] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)


Test command: ./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.reader.viewmodels.ReaderTagsFeedViewModelTest" --tests "org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedUiStateMapperTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt
index c4ce39ad344c..b54fdb83f60f 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt
@@ -2,28 +2,8 @@ package org.wordpress.android.ui.reader
 
 import android.os.Bundle
 import android.view.View
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.horizontalScroll
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextDecoration
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
 import androidx.fragment.app.viewModels
 import androidx.lifecycle.ViewModelProvider
 import dagger.hilt.android.AndroidEntryPoint
@@ -37,8 +17,9 @@ import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarte
 import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel
 import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel.Companion.getViewModelKeyForTag
 import org.wordpress.android.ui.reader.subfilter.SubfilterListItem
-import org.wordpress.android.ui.reader.viewmodels.ReaderTagsFeedViewModel
+import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel
 import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel
+import org.wordpress.android.ui.reader.views.compose.tagsfeed.ReaderTagsFeed
 import org.wordpress.android.util.NetworkUtils
 import org.wordpress.android.util.extensions.getSerializableCompat
 import javax.inject.Inject
@@ -79,10 +60,7 @@ class ReaderTagsFeedFragment : ViewPagerFragment(R.layout.reader_tag_feed_fragme
         binding.composeView.setContent {
             AppThemeWithoutBackground {
                 val uiState by viewModel.uiStateFlow.collectAsState()
-                ReaderTagsFeedScreen(
-                    uiState = uiState,
-                    onRetryClicked = viewModel::fetchTag,
-                )
+                ReaderTagsFeed(uiState)
             }
         }
 
@@ -104,6 +82,7 @@ class ReaderTagsFeedFragment : ViewPagerFragment(R.layout.reader_tag_feed_fragme
             }
         }
 
+        // TODO not triggered when there's no internet, so the error/no connection UI is not shown.
         subFilterViewModel.subFilters.observe(viewLifecycleOwner) { subFilters ->
             readerViewModel.showTopBarFilterGroup(
                 tagsFeedTag,
@@ -111,7 +90,7 @@ class ReaderTagsFeedFragment : ViewPagerFragment(R.layout.reader_tag_feed_fragme
             )
 
             val tags = subFilters.filterIsInstance<SubfilterListItem.Tag>().map { it.tag }
-            viewModel.fetchAll(tags)
+            viewModel.start(tags)
         }
         subFilterViewModel.updateTagsAndSites()
     }
@@ -136,97 +115,3 @@ class ReaderTagsFeedFragment : ViewPagerFragment(R.layout.reader_tag_feed_fragme
         }
     }
 }
-
-/**
- * Throwaway UI code just for testing the initial Tags Feed fetching code.
- * TODO remove this and replace with the final Compose content.
- */
-@Composable
-private fun ReaderTagsFeedScreen(
-    uiState: ReaderTagsFeedViewModel.UiState,
-    onRetryClicked: (ReaderTag) -> Unit,
-) {
-    Column(
-        modifier = Modifier
-            .fillMaxHeight()
-            .verticalScroll(rememberScrollState())
-            .padding(16.dp),
-        verticalArrangement = Arrangement.spacedBy(8.dp),
-    ) {
-        uiState.tagStates.forEach { (tag, fetchState) ->
-            Column(
-                verticalArrangement = Arrangement.spacedBy(4.dp),
-            ) {
-                Text(
-                    text = tag.tagTitle,
-                    style = MaterialTheme.typography.h4,
-                )
-
-                when (fetchState) {
-                    is ReaderTagsFeedViewModel.FetchState.Loading -> {
-                        Text(
-                            text = "Loading...",
-                            style = MaterialTheme.typography.body1,
-                        )
-                    }
-
-                    is ReaderTagsFeedViewModel.FetchState.Error -> {
-                        Row(
-                            horizontalArrangement = Arrangement.spacedBy(2.dp),
-                        ) {
-                            Text(
-                                text = "Error loading posts.",
-                                style = MaterialTheme.typography.body1,
-                            )
-
-                            Text(
-                                text = "Retry",
-                                style = MaterialTheme.typography.body1,
-                                textDecoration = TextDecoration.Underline,
-                                color = MaterialTheme.colors.primary,
-                                modifier = Modifier
-                                    .padding(start = 8.dp)
-                                    .clickable { onRetryClicked(tag) },
-                            )
-                        }
-                    }
-
-                    is ReaderTagsFeedViewModel.FetchState.Success -> {
-                        Row(
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .horizontalScroll(rememberScrollState()),
-                        ) {
-                            fetchState.posts.forEach { post ->
-                                Column(
-                                    modifier = Modifier
-                                        .width(300.dp)
-                                        .background(
-                                            MaterialTheme.colors.surface,
-                                            RoundedCornerShape(4.dp)
-                                        )
-                                        .padding(4.dp),
-                                    verticalArrangement = Arrangement.spacedBy(4.dp),
-                                ) {
-                                    Text(
-                                        text = post.title,
-                                        style = MaterialTheme.typography.h5,
-                                        maxLines = 2,
-                                        overflow = TextOverflow.Ellipsis,
-                                    )
-
-                                    Text(
-                                        text = post.excerpt,
-                                        style = MaterialTheme.typography.body1,
-                                        maxLines = 4,
-                                        overflow = TextOverflow.Ellipsis,
-                                    )
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt
index 9c24b917023f..7fbb8e88556e 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt
@@ -35,7 +35,7 @@ class ReaderPostRepository @Inject constructor(
      * Fetches and returns the most recent posts for the passed tag, respecting the maxPosts limit.
      * It always fetches the most recent posts, saves them to the local DB and returns the latest from that cache.
      */
-    suspend fun fetchNewerPostsForTag(tag: ReaderTag, maxPosts: Int = 7): ReaderPostList {
+    suspend fun fetchNewerPostsForTag(tag: ReaderTag, maxPosts: Int = 10): ReaderPostList {
         return suspendCancellableCoroutine { cont ->
             val resultListener = UpdateResultListener { result ->
                 if (result == ReaderActions.UpdateResult.FAILED) {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtilsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtilsWrapper.kt
index 68d27dacaeb9..aacaf2d52eae 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtilsWrapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtilsWrapper.kt
@@ -63,4 +63,10 @@ class ReaderUtilsWrapper @Inject constructor(
     fun isSelfHosted(authorBlogId: Long) = ReaderUtils.isSelfHosted(authorBlogId)
 
     fun getTagFromTagUrl(url: String): String = ReaderUtils.getTagFromTagUrl(url)
+
+    fun getShortLikeLabelText(numLikes: Int): String =
+        ReaderUtils.getShortLikeLabelText(contextProvider.getContext(), numLikes)
+
+    fun getShortCommentLabelText(numComments: Int): String =
+        ReaderUtils.getShortCommentLabelText(contextProvider.getContext(), numComments)
 }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModel.kt
deleted file mode 100644
index 6016df2cc87a..000000000000
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModel.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package org.wordpress.android.ui.reader.viewmodels
-
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.update
-import org.wordpress.android.models.ReaderPostList
-import org.wordpress.android.models.ReaderTag
-import org.wordpress.android.modules.BG_THREAD
-import org.wordpress.android.ui.reader.exceptions.ReaderPostFetchException
-import org.wordpress.android.ui.reader.repository.ReaderPostRepository
-import org.wordpress.android.viewmodel.ScopedViewModel
-import javax.inject.Inject
-import javax.inject.Named
-
-@HiltViewModel
-class ReaderTagsFeedViewModel @Inject constructor(
-    @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
-    private val readerPostRepository: ReaderPostRepository,
-) : ScopedViewModel(bgDispatcher) {
-    private val _uiStateFlow = MutableStateFlow(UiState(emptyMap()))
-    val uiStateFlow: StateFlow<UiState> = _uiStateFlow
-
-    /**
-     * Fetch multiple tag posts in parallel. Each tag load causes a new state to be emitted, so multiple emissions of
-     * [uiStateFlow] are expected when calling this method for each tag, since each can go through the following
-     * [FetchState]s: [FetchState.Loading], [FetchState.Success], [FetchState.Error].
-     */
-    fun fetchAll(tags: List<ReaderTag>) {
-        tags.forEach {
-            fetchTag(it)
-        }
-    }
-
-    /**
-     * Fetch posts for a single tag. This method will emit a new state to [uiStateFlow] for different [FetchState]s:
-     * [FetchState.Loading], [FetchState.Success], [FetchState.Error], but only for the tag being fetched.
-     *
-     * Can be used for retrying a failed fetch, for instance.
-     */
-    fun fetchTag(tag: ReaderTag) {
-        launch {
-            _uiStateFlow.update {
-                it.copy(tagStates = it.tagStates + (tag to FetchState.Loading))
-            }
-
-            try {
-                val posts = readerPostRepository.fetchNewerPostsForTag(tag)
-                _uiStateFlow.update {
-                    it.copy(tagStates = it.tagStates + (tag to FetchState.Success(posts)))
-                }
-            } catch (e: ReaderPostFetchException) {
-                _uiStateFlow.update {
-                    it.copy(tagStates = it.tagStates + (tag to FetchState.Error(e)))
-                }
-            }
-        }
-    }
-
-    data class UiState(
-        val tagStates: Map<ReaderTag, FetchState>,
-    )
-
-    sealed class FetchState {
-        data object Loading : FetchState()
-        data class Success(val posts: ReaderPostList) : FetchState()
-        data class Error(val exception: Exception) : FetchState()
-    }
-}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt
new file mode 100644
index 000000000000..b591258043f1
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt
@@ -0,0 +1,86 @@
+package org.wordpress.android.ui.reader.viewmodels.tagsfeed
+
+import org.wordpress.android.models.ReaderPostList
+import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper
+import org.wordpress.android.ui.reader.views.compose.tagsfeed.TagsFeedPostItem
+import org.wordpress.android.util.DateTimeUtilsWrapper
+import javax.inject.Inject
+
+class ReaderTagsFeedUiStateMapper @Inject constructor(
+    private val dateTimeUtilsWrapper: DateTimeUtilsWrapper,
+    private val readerUtilsWrapper: ReaderUtilsWrapper,
+) {
+    @Suppress("LongParameterList")
+    fun mapLoadedTagFeedItem(
+        tag: ReaderTag,
+        posts: ReaderPostList,
+        onTagClick: () -> Unit,
+        onSiteClick: () -> Unit,
+        onPostImageClick: () -> Unit,
+        onPostLikeClick: () -> Unit,
+        onPostMoreMenuClick: () -> Unit,
+    ) = ReaderTagsFeedViewModel.TagFeedItem(
+        tagChip = ReaderTagsFeedViewModel.TagChip(
+            tag = tag,
+            onTagClick = onTagClick,
+        ),
+        postList = ReaderTagsFeedViewModel.PostList.Loaded(
+            posts.map {
+                TagsFeedPostItem(
+                    siteName = it.blogName,
+                    postDateLine = dateTimeUtilsWrapper.javaDateToTimeSpan(
+                        it.getDisplayDate(dateTimeUtilsWrapper)
+                    ),
+                    postTitle = it.title,
+                    postExcerpt = it.excerpt,
+                    postImageUrl = it.blogImageUrl,
+                    postNumberOfLikesText = readerUtilsWrapper.getShortLikeLabelText(
+                        numLikes = it.numLikes
+                    ),
+                    postNumberOfCommentsText = readerUtilsWrapper.getShortCommentLabelText(
+                        numComments = it.numReplies
+                    ),
+                    isPostLiked = it.isLikedByCurrentUser,
+                    onSiteClick = onSiteClick,
+                    onPostImageClick = onPostImageClick,
+                    onPostLikeClick = onPostLikeClick,
+                    onPostMoreMenuClick = onPostMoreMenuClick,
+                )
+            }
+        ),
+    )
+
+    fun mapErrorTagFeedItem(
+        tag: ReaderTag,
+        errorType: ReaderTagsFeedViewModel.ErrorType,
+        onTagClick: () -> Unit,
+        onRetryClick: () -> Unit,
+    ): ReaderTagsFeedViewModel.TagFeedItem =
+        ReaderTagsFeedViewModel.TagFeedItem(
+            tagChip = ReaderTagsFeedViewModel.TagChip(
+                tag = tag,
+                onTagClick = onTagClick,
+            ),
+            postList = ReaderTagsFeedViewModel.PostList.Error(
+                type = errorType,
+                onRetryClick = onRetryClick,
+            ),
+        )
+
+    fun mapLoadingPostsUiState(
+        tags: List<ReaderTag>,
+        onTagClick: () -> Unit,
+    ): ReaderTagsFeedViewModel.UiState.Loaded =
+        ReaderTagsFeedViewModel.UiState.Loaded(
+            tags.map { tag ->
+                ReaderTagsFeedViewModel.TagFeedItem(
+                    tagChip = ReaderTagsFeedViewModel.TagChip(
+                        tag = tag,
+                        onTagClick = onTagClick,
+                    ),
+                    postList = ReaderTagsFeedViewModel.PostList.Loading,
+                )
+            }
+        )
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt
new file mode 100644
index 000000000000..de71d090c6c1
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt
@@ -0,0 +1,177 @@
+package org.wordpress.android.ui.reader.viewmodels.tagsfeed
+
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.modules.BG_THREAD
+import org.wordpress.android.ui.reader.exceptions.ReaderPostFetchException
+import org.wordpress.android.ui.reader.repository.ReaderPostRepository
+import org.wordpress.android.ui.reader.views.compose.tagsfeed.TagsFeedPostItem
+import org.wordpress.android.viewmodel.ScopedViewModel
+import javax.inject.Inject
+import javax.inject.Named
+
+@HiltViewModel
+class ReaderTagsFeedViewModel @Inject constructor(
+    @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
+    private val readerPostRepository: ReaderPostRepository,
+    private val readerTagsFeedUiStateMapper: ReaderTagsFeedUiStateMapper,
+) : ScopedViewModel(bgDispatcher) {
+    private val _uiStateFlow: MutableStateFlow<UiState> = MutableStateFlow(UiState.Initial)
+    val uiStateFlow: StateFlow<UiState> = _uiStateFlow
+
+    /**
+     * Fetch multiple tag posts in parallel. Each tag load causes a new state to be emitted, so multiple emissions of
+     * [uiStateFlow] are expected when calling this method for each tag, since each can go through the following
+     * [UiState]s: [UiState.Initial], [UiState.Loaded], [UiState.Loading], [UiState.Empty].
+     */
+    fun start(tags: List<ReaderTag>) {
+        if (tags.isEmpty()) {
+            _uiStateFlow.value = UiState.Empty(::onOpenTagsListClick)
+            return
+        }
+        // Initially add all tags to the list with the posts loading UI
+        _uiStateFlow.update {
+            readerTagsFeedUiStateMapper.mapLoadingPostsUiState(tags, ::onTagClick)
+        }
+        // Fetch all posts and update the posts loading UI to either loaded or error when the request finishes
+        launch {
+            tags.forEach {
+                fetchTag(it)
+            }
+        }
+    }
+
+    /**
+     * Fetch posts for a single tag. This method will emit a new state to [uiStateFlow] for different [UiState]s:
+     * [UiState.Initial], [UiState.Loaded], [UiState.Loading], [UiState.Empty], but only for the tag being fetched.
+     *
+     * Can be used for retrying a failed fetch, for instance.
+     */
+    @Suppress("SwallowedException")
+    private suspend fun fetchTag(tag: ReaderTag) {
+        val updatedLoadedData = getUpdatedLoadedData()
+        // At this point, all tag feed items already exist in the UI with the loading status.
+        // We need it's index to update it to either Loaded or Error when the request is finished.
+        val existingIndex = updatedLoadedData.indexOfFirst { it.tagChip.tag == tag }
+        // Remove the current row of this tag (which is loading). This will be used to later add an updated item with
+        // either Loaded or Error status, depending on the result of the request.
+        updatedLoadedData.removeAll { it.tagChip.tag == tag }
+        try {
+            // Fetch posts for tag
+            val posts = readerPostRepository.fetchNewerPostsForTag(tag)
+            if (posts.isNotEmpty()) {
+                updatedLoadedData.add(
+                    existingIndex,
+                    readerTagsFeedUiStateMapper.mapLoadedTagFeedItem(
+                        tag = tag,
+                        posts = posts,
+                        onTagClick = ::onTagClick,
+                        onSiteClick = ::onSiteClick,
+                        onPostImageClick = ::onPostImageClick,
+                        onPostLikeClick = ::onPostLikeClick,
+                        onPostMoreMenuClick = ::onPostMoreMenuClick,
+                    )
+                )
+            } else {
+                updatedLoadedData.add(
+                    existingIndex,
+                    readerTagsFeedUiStateMapper.mapErrorTagFeedItem(
+                        tag = tag,
+                        errorType = ErrorType.NoContent,
+                        onTagClick = ::onTagClick,
+                        onRetryClick = ::onRetryClick,
+                    )
+                )
+            }
+        } catch (e: ReaderPostFetchException) {
+            updatedLoadedData.add(
+                existingIndex,
+                readerTagsFeedUiStateMapper.mapErrorTagFeedItem(
+                    tag = tag,
+                    errorType = ErrorType.Default,
+                    onTagClick = ::onTagClick,
+                    onRetryClick = ::onRetryClick,
+                )
+            )
+        }
+        _uiStateFlow.update { UiState.Loaded(updatedLoadedData) }
+    }
+
+    private fun getUpdatedLoadedData(): MutableList<TagFeedItem> {
+        val updatedLoadedData = mutableListOf<TagFeedItem>()
+        val currentUiState = _uiStateFlow.value
+        if (currentUiState is UiState.Loaded) {
+            val currentLoadedData = currentUiState.data
+            updatedLoadedData.addAll(currentLoadedData)
+        }
+        return updatedLoadedData
+    }
+
+    private fun onOpenTagsListClick() {
+        // TODO
+    }
+
+    private fun onTagClick() {
+        // TODO
+    }
+
+    private fun onRetryClick() {
+        // TODO
+    }
+
+    private fun onSiteClick() {
+        // TODO
+    }
+
+    private fun onPostImageClick() {
+        // TODO
+    }
+
+    private fun onPostLikeClick() {
+        // TODO
+    }
+
+    private fun onPostMoreMenuClick() {
+        // TODO
+    }
+
+    sealed class UiState {
+        object Initial : UiState()
+        data class Loaded(val data: List<TagFeedItem>) : UiState()
+
+        object Loading : UiState()
+
+        data class Empty(val onOpenTagsListClick: () -> Unit) : UiState()
+    }
+
+    data class TagFeedItem(
+        val tagChip: TagChip,
+        val postList: PostList,
+    )
+
+    data class TagChip(
+        val tag: ReaderTag,
+        val onTagClick: () -> Unit,
+    )
+
+    sealed class PostList {
+        data class Loaded(val items: List<TagsFeedPostItem>) : PostList()
+
+        object Loading : PostList()
+
+        data class Error(
+            val type: ErrorType,
+            val onRetryClick: () -> Unit
+        ) : PostList()
+    }
+
+    sealed interface ErrorType {
+        data object Default : ErrorType
+
+        data object NoContent : ErrorType
+    }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt
index 172e55c19197..471e00631133 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt
@@ -48,6 +48,11 @@ import org.wordpress.android.models.ReaderTagType
 import org.wordpress.android.ui.compose.theme.AppColor
 import org.wordpress.android.ui.compose.theme.AppTheme
 import org.wordpress.android.ui.compose.unit.Margin
+import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.ErrorType
+import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.PostList
+import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.TagChip
+import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.TagFeedItem
+import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.UiState
 import org.wordpress.android.ui.reader.views.compose.filter.ReaderFilterChip
 import org.wordpress.android.ui.utils.UiString
 import org.wordpress.android.util.AppLog
@@ -63,6 +68,9 @@ fun ReaderTagsFeed(uiState: UiState) {
             is UiState.Loading -> Loading()
             is UiState.Loaded -> Loaded(uiState)
             is UiState.Empty -> Empty(uiState)
+            is UiState.Initial -> {
+                // no-op
+            }
         }
     }
 }
@@ -88,7 +96,7 @@ private fun Loaded(uiState: UiState.Loaded) {
                     start = Margin.Large.value,
                 ),
                 text = UiString.UiStringText(tagChip.tag.tagTitle),
-                onClick = tagChip.onTagClicked,
+                onClick = tagChip.onTagClick,
                 height = 36.dp,
             )
             Spacer(modifier = Modifier.height(Margin.Large.value))
@@ -292,7 +300,7 @@ private fun PostListLoaded(
                             interactionSource = remember { MutableInteractionSource() },
                             indication = rememberRipple(bounded = false),
                             onClick = {
-                                tagChip.onTagClicked()
+                                tagChip.onTagClick()
                                 AppLog.e(AppLog.T.READER, "RL-> Tag clicked")
                             }
                         ),
@@ -366,7 +374,7 @@ private fun PostListError(
         )
         Spacer(modifier = Modifier.height(Margin.Medium.value))
         val errorMessage = when (postList.type) {
-            is ErrorType.Loading -> stringResource(R.string.reader_tags_feed_loading_error_description)
+            is ErrorType.Default -> stringResource(R.string.reader_tags_feed_loading_error_description)
             is ErrorType.NoContent -> stringResource(R.string.reader_tags_feed_no_content_error_description, tagName)
         }
         Text(
@@ -407,42 +415,6 @@ private fun PostListError(
     }
 }
 
-// TODO move to VM
-sealed class UiState {
-    data class Loaded(val data: List<TagFeedItem>) : UiState()
-
-    object Loading : UiState()
-
-    data class Empty(val onOpenTagsListClick: () -> Unit) : UiState()
-}
-
-data class TagFeedItem(
-    val tagChip: TagChip,
-    val postList: PostList,
-)
-
-data class TagChip(
-    val tag: ReaderTag,
-    val onTagClicked: () -> Unit,
-)
-
-sealed class PostList {
-    data class Loaded(val items: List<TagsFeedPostItem>) : PostList()
-
-    object Loading : PostList()
-
-    data class Error(
-        val type: ErrorType,
-        val onRetryClick: () -> Unit
-    ) : PostList()
-}
-
-sealed interface ErrorType {
-    data object Loading : ErrorType
-
-    data object NoContent : ErrorType
-}
-
 data class TagsFeedPostItem(
     val siteName: String,
     val postDateLine: String,
@@ -557,7 +529,7 @@ fun ReaderTagsFeedLoaded() {
                     ),
                     TagFeedItem(
                         tagChip = TagChip(readerTag, {}),
-                        postList = PostList.Error(ErrorType.Loading, {}),
+                        postList = PostList.Error(ErrorType.Default, {}),
                     ),
                     TagFeedItem(
                         tagChip = TagChip(readerTag, {}),
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItem.kt
index 977ec18646f1..f3c724e30b98 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItem.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItem.kt
@@ -140,7 +140,7 @@ fun ReaderTagsFeedPostListItem(
             text = postExcerpt,
             style = MaterialTheme.typography.bodySmall,
             color = primaryElementColor,
-            maxLines = if (!postImageUrl.isNullOrBlank()) 2 else Int.MAX_VALUE,
+            maxLines = if (!postImageUrl.isNullOrBlank()) 2 else 10,
             overflow = TextOverflow.Ellipsis,
         )
         // Post image


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt
index d4008189759b..3e55571fc74a 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt
@@ -9,100 +9,124 @@ import org.assertj.core.api.Assertions.assertThat
 import org.junit.Before
 import org.junit.Test
 import org.mockito.Mock
+import org.mockito.kotlin.any
 import org.mockito.kotlin.doSuspendableAnswer
 import org.mockito.kotlin.whenever
 import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.models.ReaderPost
 import org.wordpress.android.models.ReaderPostList
 import org.wordpress.android.models.ReaderTag
 import org.wordpress.android.models.ReaderTagType
 import org.wordpress.android.ui.reader.exceptions.ReaderPostFetchException
 import org.wordpress.android.ui.reader.repository.ReaderPostRepository
-import org.wordpress.android.ui.reader.viewmodels.ReaderTagsFeedViewModel.FetchState
+import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedUiStateMapper
+import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class ReaderTagsFeedViewModelTest : BaseUnitTest() {
     @Mock
     lateinit var readerPostRepository: ReaderPostRepository
 
+    @Mock
+    lateinit var readerTagsFeedUiStateMapper: ReaderTagsFeedUiStateMapper
+
     private lateinit var viewModel: ReaderTagsFeedViewModel
 
     private val collectedUiStates: MutableList<ReaderTagsFeedViewModel.UiState> = mutableListOf()
 
+    val tag = ReaderTag(
+        "tag",
+        "tag",
+        "tag",
+        "endpoint",
+        ReaderTagType.FOLLOWED,
+    )
+
+    private val postListLoadingItem = ReaderTagsFeedViewModel.TagFeedItem(
+        tagChip = ReaderTagsFeedViewModel.TagChip(
+            tag = tag,
+            onTagClick = {},
+        ),
+        postList = ReaderTagsFeedViewModel.PostList.Loading,
+    )
+
     @Before
     fun setUp() {
-        viewModel = ReaderTagsFeedViewModel(testDispatcher(), readerPostRepository)
+        viewModel = ReaderTagsFeedViewModel(testDispatcher(), readerPostRepository, readerTagsFeedUiStateMapper)
     }
 
     @Test
     fun `given valid tag, when fetchTag, then UI state should update properly`() = testCollectingUiStates {
         // Given
-        val tag = ReaderTag(
-            "tag",
-            "tag",
-            "tag",
-            "endpoint",
-            ReaderTagType.FOLLOWED,
+        val tagFeedItem = ReaderTagsFeedViewModel.TagFeedItem(
+            ReaderTagsFeedViewModel.TagChip(tag, {}),
+            ReaderTagsFeedViewModel.PostList.Loaded(listOf())
         )
-        val posts = ReaderPostList()
+        val posts = ReaderPostList().apply {
+            add(ReaderPost())
+        }
         whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer {
             delay(100)
             posts
         }
+        whenever(readerTagsFeedUiStateMapper.mapLoadingPostsUiState(any(), any()))
+            .thenReturn(
+                ReaderTagsFeedViewModel.UiState.Loaded(
+                    listOf(postListLoadingItem, postListLoadingItem)
+                )
+            )
+        whenever(readerTagsFeedUiStateMapper.mapLoadedTagFeedItem(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(tagFeedItem)
 
         // When
-        viewModel.fetchTag(tag)
+        viewModel.start(listOf(tag))
         advanceUntilIdle()
 
         // Then
         assertThat(collectedUiStates).contains(
-            ReaderTagsFeedViewModel.UiState(
-                mapOf(
-                    tag to FetchState.Loading,
-                )
-            ),
-            ReaderTagsFeedViewModel.UiState(
-                mapOf(
-                    tag to FetchState.Success(posts),
-                )
-            ),
+            ReaderTagsFeedViewModel.UiState.Loaded(
+                data = listOf(tagFeedItem)
+            )
         )
     }
 
     @Test
     fun `given invalid tag, when fetchTag, then UI state should update properly`() = testCollectingUiStates {
         // Given
-        val tag = ReaderTag(
-            "tag",
-            "tag",
-            "tag",
-            "endpoint",
-            ReaderTagType.FOLLOWED,
-        )
         val error = ReaderPostFetchException("error")
+        val tagFeedItem = ReaderTagsFeedViewModel.TagFeedItem(
+            ReaderTagsFeedViewModel.TagChip(tag, {}),
+            ReaderTagsFeedViewModel.PostList.Error(
+                ReaderTagsFeedViewModel.ErrorType.Default, {}
+            ),
+        )
         whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer {
             delay(100)
             throw error
         }
+        whenever(readerTagsFeedUiStateMapper.mapLoadingPostsUiState(any(), any()))
+            .thenReturn(
+                ReaderTagsFeedViewModel.UiState.Loaded(
+                    listOf(postListLoadingItem, postListLoadingItem)
+                )
+            )
+        whenever(readerTagsFeedUiStateMapper.mapErrorTagFeedItem(any(), any(), any(), any()))
+            .thenReturn(tagFeedItem)
 
         // When
-        viewModel.fetchTag(tag)
+        viewModel.start(listOf(tag))
+//        viewModel.fetchTag(tag)
         advanceUntilIdle()
 
         // Then
         assertThat(collectedUiStates).contains(
-            ReaderTagsFeedViewModel.UiState(
-                mapOf(
-                    tag to FetchState.Loading,
-                )
-            ),
-            ReaderTagsFeedViewModel.UiState(
-                mapOf(
-                    tag to FetchState.Error(error),
-                )
-            ),
+            ReaderTagsFeedViewModel.UiState.Loaded(
+                data = listOf(tagFeedItem)
+            )
         )
     }
 
+    @Suppress("LongMethod")
     @Test
     fun `given valid tags, when fetchAll, then UI state should update properly`() = testCollectingUiStates {
         // Given
@@ -120,8 +144,12 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
             "endpoint2",
             ReaderTagType.FOLLOWED,
         )
-        val posts1 = ReaderPostList()
-        val posts2 = ReaderPostList()
+        val posts1 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
+        val posts2 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
         whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer {
             delay(100)
             posts1
@@ -130,39 +158,50 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
             delay(200)
             posts2
         }
+        whenever(readerTagsFeedUiStateMapper.mapLoadingPostsUiState(any(), any()))
+            .thenReturn(
+                ReaderTagsFeedViewModel.UiState.Loaded(
+                    listOf(
+                        ReaderTagsFeedViewModel.TagFeedItem(
+                            tagChip = ReaderTagsFeedViewModel.TagChip(
+                                tag = tag1,
+                                onTagClick = {},
+                            ),
+                            postList = ReaderTagsFeedViewModel.PostList.Loading,
+                        ),
+                        ReaderTagsFeedViewModel.TagFeedItem(
+                            tagChip = ReaderTagsFeedViewModel.TagChip(
+                                tag = tag2,
+                                onTagClick = {},
+                            ),
+                            postList = ReaderTagsFeedViewModel.PostList.Loading,
+                        ),
+                    )
+                )
+            )
+        val tagFeedItem = ReaderTagsFeedViewModel.TagFeedItem(
+            ReaderTagsFeedViewModel.TagChip(tag1, {}),
+            ReaderTagsFeedViewModel.PostList.Loaded(listOf()),
+        )
+        whenever(readerTagsFeedUiStateMapper.mapLoadedTagFeedItem(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(tagFeedItem)
 
         // When
-        viewModel.fetchAll(listOf(tag1, tag2))
+        viewModel.start(listOf(tag1, tag2))
         advanceUntilIdle()
 
         // Then
-
-        // tag 1
-        assertThat(collectedUiStates).anyMatch {
-            it.tagStates[tag1] == FetchState.Loading
-        }
-        assertThat(collectedUiStates).anyMatch {
-            it.tagStates[tag1] == FetchState.Success(posts1)
-        }
-
-        // tag 2
-        assertThat(collectedUiStates).anyMatch {
-            it.tagStates[tag2] == FetchState.Loading
-        }
-        assertThat(collectedUiStates).anyMatch {
-            it.tagStates[tag2] == FetchState.Success(posts1)
-        }
-
-        assertThat(collectedUiStates.last()).isEqualTo(
-            ReaderTagsFeedViewModel.UiState(
-                mapOf(
-                    tag1 to FetchState.Success(posts1),
-                    tag2 to FetchState.Success(posts2),
+        assertThat(collectedUiStates).contains(
+            ReaderTagsFeedViewModel.UiState.Loaded(
+                data = listOf(
+                    tagFeedItem,
+                    tagFeedItem,
                 )
             )
         )
     }
 
+    @Suppress("LongMethod")
     @Test
     fun `given valid and invalid tags, when fetchAll, then UI state should update properly`() = testCollectingUiStates {
         // Given
@@ -180,7 +219,9 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
             "endpoint2",
             ReaderTagType.FOLLOWED,
         )
-        val posts1 = ReaderPostList()
+        val posts1 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
         val error2 = ReaderPostFetchException("error")
         whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer {
             delay(100)
@@ -190,34 +231,52 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
             delay(200)
             throw error2
         }
+        whenever(readerTagsFeedUiStateMapper.mapLoadingPostsUiState(any(), any()))
+            .thenReturn(
+                ReaderTagsFeedViewModel.UiState.Loaded(
+                    listOf(
+                        ReaderTagsFeedViewModel.TagFeedItem(
+                            tagChip = ReaderTagsFeedViewModel.TagChip(
+                                tag = tag1,
+                                onTagClick = {},
+                            ),
+                            postList = ReaderTagsFeedViewModel.PostList.Loading,
+                        ),
+                        ReaderTagsFeedViewModel.TagFeedItem(
+                            tagChip = ReaderTagsFeedViewModel.TagChip(
+                                tag = tag2,
+                                onTagClick = {},
+                            ),
+                            postList = ReaderTagsFeedViewModel.PostList.Loading,
+                        )
+                    )
+                )
+            )
+        val tagFeedItemLoaded = ReaderTagsFeedViewModel.TagFeedItem(
+            ReaderTagsFeedViewModel.TagChip(tag1, {}),
+            ReaderTagsFeedViewModel.PostList.Loaded(listOf())
+        )
+        val tagFeedItemError = ReaderTagsFeedViewModel.TagFeedItem(
+            ReaderTagsFeedViewModel.TagChip(tag2, {}),
+            ReaderTagsFeedViewModel.PostList.Error(
+                ReaderTagsFeedViewModel.ErrorType.Default, {}
+            )
+        )
+        whenever(readerTagsFeedUiStateMapper.mapLoadedTagFeedItem(any(), any(), any(), any(), any(), any(), any()))
+            .thenReturn(tagFeedItemLoaded)
+        whenever(readerTagsFeedUiStateMapper.mapErrorTagFeedItem(any(), any(), any(), any()))
+            .thenReturn(tagFeedItemError)
 
         // When
-        viewModel.fetchAll(listOf(tag1, tag2))
+        viewModel.start(listOf(tag1, tag2))
         advanceUntilIdle()
 
         // Then
-
-        // tag 1
-        assertThat(collectedUiStates).anyMatch {
-            it.tagStates[tag1] == FetchState.Loading
-        }
-        assertThat(collectedUiStates).anyMatch {
-            it.tagStates[tag1] == FetchState.Success(posts1)
-        }
-
-        // tag 2
-        assertThat(collectedUiStates).anyMatch {
-            it.tagStates[tag2] == FetchState.Loading
-        }
-        assertThat(collectedUiStates).anyMatch {
-            it.tagStates[tag2] == FetchState.Error(error2)
-        }
-
-        assertThat(collectedUiStates.last()).isEqualTo(
-            ReaderTagsFeedViewModel.UiState(
-                mapOf(
-                    tag1 to FetchState.Success(posts1),
-                    tag2 to FetchState.Error(error2),
+        assertThat(collectedUiStates).contains(
+            ReaderTagsFeedViewModel.UiState.Loaded(
+                data = listOf(
+                    tagFeedItemLoaded,
+                    tagFeedItemError,
                 )
             )
         )
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt
new file mode 100644
index 000000000000..c88e8ac499b0
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt
@@ -0,0 +1,193 @@
+package org.wordpress.android.ui.reader.viewmodels.tagsfeed
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.models.ReaderPost
+import org.wordpress.android.models.ReaderPostList
+import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.models.ReaderTagType
+import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper
+import org.wordpress.android.ui.reader.views.compose.tagsfeed.TagsFeedPostItem
+import org.wordpress.android.util.DateTimeUtilsWrapper
+import java.util.Date
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReaderTagsFeedUiStateMapperTest : BaseUnitTest() {
+    private val dateTimeUtilsWrapper = mock<DateTimeUtilsWrapper>()
+
+    private val readerUtilsWrapper = mock<ReaderUtilsWrapper>()
+
+    private val classToTest: ReaderTagsFeedUiStateMapper = ReaderTagsFeedUiStateMapper(
+        dateTimeUtilsWrapper = dateTimeUtilsWrapper,
+        readerUtilsWrapper = readerUtilsWrapper,
+    )
+
+    @Suppress("LongMethod")
+    @Test
+    fun `Should map loaded TagFeedItem correctly`() {
+        // Given
+        val readerPost = ReaderPost().apply {
+            blogName = "Name"
+            title = "Title"
+            excerpt = "Excerpt"
+            blogImageUrl = "url"
+            numLikes = 5
+            numReplies = 10
+            isLikedByCurrentUser = true
+            datePublished = ""
+        }
+        val postList = ReaderPostList().apply {
+            add(readerPost)
+        }
+        val readerTag = ReaderTag(
+            "tag",
+            "tag",
+            "tag",
+            "endpoint",
+            ReaderTagType.FOLLOWED,
+        )
+        val onTagClick = {}
+        val onSiteClick = {}
+        val onPostImageClick = {}
+        val onPostLikeClick = {}
+        val onPostMoreMenuClick = {}
+
+        val dateLine = "dateLine"
+        val numberLikesText = "numberLikesText"
+        val numberCommentsText = "numberCommentsText"
+
+        // When
+        whenever(dateTimeUtilsWrapper.dateFromIso8601(any()))
+            .thenReturn(Date(0))
+        whenever(dateTimeUtilsWrapper.javaDateToTimeSpan(any()))
+            .thenReturn(dateLine)
+        whenever(readerUtilsWrapper.getShortLikeLabelText(readerPost.numLikes))
+            .thenReturn(numberLikesText)
+        whenever(readerUtilsWrapper.getShortCommentLabelText(readerPost.numReplies))
+            .thenReturn(numberCommentsText)
+
+        val actual = classToTest.mapLoadedTagFeedItem(
+            tag = readerTag,
+            posts = postList,
+            onTagClick = onTagClick,
+            onSiteClick = onSiteClick,
+            onPostImageClick = onPostImageClick,
+            onPostLikeClick = onPostLikeClick,
+            onPostMoreMenuClick = onPostMoreMenuClick,
+        )
+        // Then
+        val expected = ReaderTagsFeedViewModel.TagFeedItem(
+            tagChip = ReaderTagsFeedViewModel.TagChip(
+                tag = readerTag,
+                onTagClick = onTagClick,
+            ),
+            postList = ReaderTagsFeedViewModel.PostList.Loaded(
+                listOf(
+                    TagsFeedPostItem(
+                        siteName = readerPost.blogName,
+                        postDateLine = dateLine,
+                        postTitle = readerPost.title,
+                        postExcerpt = readerPost.excerpt,
+                        postImageUrl = readerPost.blogImageUrl,
+                        postNumberOfLikesText = numberLikesText,
+                        postNumberOfCommentsText = numberCommentsText,
+                        isPostLiked = readerPost.isLikedByCurrentUser,
+                        onSiteClick = onSiteClick,
+                        onPostLikeClick = onPostLikeClick,
+                        onPostImageClick = onPostImageClick,
+                        onPostMoreMenuClick = onPostMoreMenuClick,
+                    )
+                )
+            ),
+        )
+        assertEquals(expected, actual)
+    }
+
+    @Test
+    fun `Should map error TagFeedItem correctly`() {
+        // Given
+        val readerTag = ReaderTag(
+            "tag",
+            "tag",
+            "tag",
+            "endpoint",
+            ReaderTagType.FOLLOWED,
+        )
+        val errorType = ReaderTagsFeedViewModel.ErrorType.Default
+        val onTagClick = {}
+        val onRetryClick = {}
+        // When
+        val actual = classToTest.mapErrorTagFeedItem(
+            tag = readerTag,
+            errorType = errorType,
+            onTagClick = onTagClick,
+            onRetryClick = onRetryClick,
+        )
+
+        // Then
+        val expected = ReaderTagsFeedViewModel.TagFeedItem(
+            tagChip = ReaderTagsFeedViewModel.TagChip(
+                tag = readerTag,
+                onTagClick = onTagClick,
+            ),
+            postList = ReaderTagsFeedViewModel.PostList.Error(
+                type = errorType,
+                onRetryClick = onRetryClick,
+            )
+        )
+        assertEquals(expected, actual)
+    }
+
+    @Test
+    fun `Should map loading posts UI state correctly`() {
+        // Given
+        val onTagClick = {}
+        val tag1 = ReaderTag(
+            "tag",
+            "tag",
+            "tag",
+            "endpoint",
+            ReaderTagType.FOLLOWED,
+        )
+        val tag2 = ReaderTag(
+            "tag2",
+            "tag2",
+            "tag2",
+            "endpoint2",
+            ReaderTagType.FOLLOWED,
+        )
+        val tags = listOf(tag1, tag2)
+
+        // When
+        val actual = classToTest.mapLoadingPostsUiState(
+            tags = tags,
+            onTagClick = onTagClick,
+        )
+
+        // Then
+        val expected = ReaderTagsFeedViewModel.UiState.Loaded(
+            data = listOf(
+                ReaderTagsFeedViewModel.TagFeedItem(
+                    tagChip = ReaderTagsFeedViewModel.TagChip(
+                        tag = tag1,
+                        onTagClick = onTagClick,
+                    ),
+                    postList = ReaderTagsFeedViewModel.PostList.Loading,
+                ),
+                ReaderTagsFeedViewModel.TagFeedItem(
+                    tagChip = ReaderTagsFeedViewModel.TagChip(
+                        tag = tag2,
+                        onTagClick = onTagClick,
+                    ),
+                    postList = ReaderTagsFeedViewModel.PostList.Loading,
+                )
+            )
+        )
+        assertEquals(expected, actual)
+    }
+}