[Reader] Show custom lists directly if there are 2 or fewer items

Fixes #20033

-----

## To Test:
- Make sure you have more than 2 custom reading lists (you can see your lists [here](https://wordpress.com/read)).
- Install JP and sign in.
- Open Reader.
- 🔍 Expand the dropdown menu: you should see the "Lists" submenu item, and when tapping on it you should see the names of your lists.

- Make sure you have 2 or less custom reading lists.
- Reload the Reader.
- 🔍 Expand the dropdown menu: instead of seeing the "Lists" submenu, you should see the custom lists items directly in the main menu.

-----

## 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 and updated unit tests

3. What automated tests I added (or what prevented me from doing so)

    - Updated `ReaderTopBarMenuHelperTest.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)

[Reader IA] Show lists directly on dropdown menu if there are 2 or fewer
We are currently grouping the Lists in a "Lists" submenu independent of the number of lists the user has, but we should only show that submenu when the user has over 2 lists.

So if the user has 2 or fewer, they should be displayed directly in the menu root.

![image](https://github.com/wordpress-mobile/WordPress-Android/assets/5091503/57e663d9-218f-4a58-a447-05a86406ea86)

Internal ref: pcdRpT-4tr-p2#comment-7237

diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt
index ba94c014f5c5..e5b979bd9bc3 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt
@@ -55,11 +55,30 @@ class ReaderTopBarMenuHelper @Inject constructor(
                 .takeIf { it.isNotEmpty() }
                 ?.let { customListsArray ->
                     add(MenuElementData.Divider)
-                    add(createCustomListsItem(customListsArray))
+                    createCustomListsItems(customListsArray)
                 }
         }
     }

+    private fun MutableList<MenuElementData>.createCustomListsItems(
+        customListsArray: SparseArrayCompat<ReaderTag>
+    ) {
+        if (customListsArray.size() > 2) {
+            // If custom lists has more than 2 items, we add a submenu called "Lists"
+            add(createCustomListsItem(customListsArray))
+        } else {
+            // If the custom lists has 2 or less items, we add the items directly without submenu
+            customListsArray.forEach { index, readerTag ->
+                add(
+                    MenuElementData.Item.Single(
+                        id = getMenuItemIdFromReaderTagIndex(index),
+                        text = UiString.UiStringText(readerTag.tagTitle),
+                    )
+                )
+            }
+        }
+    }
+
     private fun createDiscoverItem(id: String): MenuElementData.Item.Single {
         return MenuElementData.Item.Single(
             id = id,

diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelperTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelperTest.kt
index 03e57616424e..e9850dab73b1 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelperTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelperTest.kt
@@ -72,6 +72,27 @@ class ReaderTopBarMenuHelperTest {
         assertThat(customList3Item.text).isEqualTo(UiStringText("custom-list-3"))
     }

+    @Test
+    fun `GIVEN custom lists has 2 items or less WHEN createMenu THEN custom lists items are shown outside a submenu`() {
+        val tags = ReaderTagList().apply {
+            add(mockFollowingTag()) // item 0
+            add(mockDiscoverTag()) // item 1
+            add(mockSavedTag()) // item 2
+            add(mockLikedTag()) // item 3
+            add(mockA8CTag()) // item 4
+            add(mockFollowedP2sTag()) // item 5
+            add(createCustomListTag("custom-list-1")) // item 6
+            add(createCustomListTag("custom-list-2")) // item 7
+        }
+        val menu = helper.createMenu(tags)
+
+        val customListItem1 = menu.findSingleItem { it.id == "6" }!!
+        assertThat(customListItem1.text).isEqualTo(UiStringText("custom-list-1"))
+
+        val customListItem2 = menu.findSingleItem { it.id == "7" }!!
+        assertThat(customListItem2.text).isEqualTo(UiStringText("custom-list-2"))
+    }
+
     @Test
     fun `GIVEN all tags are available and tags FF enabled WHEN createMenu THEN all items are created correctly`() {
         whenever(readerTagsFeedFeatureConfig.isEnabled()).thenReturn(true)


Post List: Fix excessive media requests

Fixes #20905

This PR fixes the excessive calls to fetch media when loading the post list by introducing and managing ongoing fetch requests in `PostListFeaturedImageTracker`. These changes aim to prevent redundant fetch requests.

Changes include
- In `PostListFeaturedImageTracker` Introduced `ongoingRequests`, to keep track of ongoing image fetch requests and prevent redundant requests.
- Updated the `getFeaturedImageUrl()` to check and update the `ongoingRequests` set appropriately.
- Updated `PostListEventListener.onMediaChanged()` to invoke `invalidateFeaturedMedia` on both success and error. This ensures that the `featuredImageMap` and `ongoingRequests` are properly updated whether the media fetch was successful or not.
-----

## To Test:
**Steps to reproduce the behavior**
**Preparation:**
1. Create multiple posts (20 or more) and attach a featured image. This can be done quickly by using the [WPCOM console](https://developer.wordpress.com/docs/api/console/) and the endpoint rest/v1.1/sites/${siteId}/posts/new.

**Existing: Extra requests to the media endpoint for the same featuredImageId**
1. Using "trunk" branch
2. On the device/emulator, **clear storage of the app**, or delete and reinstall the app.
3. Open the app and navigate to the site (the same site that you created the 20+ posts with featured images)
4. Connect the device/emulator to the Charles proxy (or see below for alternative)
5. Open the app and go to the Post List screen.
6. Observe all extra requests to the media endpoint in the Charles proxy.

**Fix: Extra requests to the media endpoint are eliminated**
1. Using the app from this PR
2. On the device/emulator, **clear storage of the app**, or delete and reinstall the app.
3. Open the app and navigate to the site (the same site that you created the 20+ posts with featured images)
4. Connect the device/emulator to the Charles proxy (or see below for alternative)
5. Open the app and go to the Post list screen.
6. ✅ Verify all the extra requests for the same media id have been eliminated.

<details>
<summary>Alternative to using Charles</summary>

- Checkout the branch you will test (trunk for pre-existing test or this branch for the fix test
- Checkout FluxC (trunk)
- Use local FluxC to build WP
- Update `GSONRequest.parseNetworkRequest` to log info

```
@Override
    protected Response<T> parseNetworkResponse(NetworkResponse response) {
        logGsonRequest();
        try {
            String json = new String(response.data, HttpHeaderParser.parseCharset(response.headers));
            T res;
            if (mClass == null) {
                res = mGson.fromJson(json, mType);
            } else {
                res = mGson.fromJson(json, mClass);
            }
            return Response.success(res, createCacheEntry(response));
        } catch (UnsupportedEncodingException | JsonSyntaxException e) {
            logRequestPath();
            return Response.error(new ParseError(e));
        }
    }

    private void logGsonRequest() {
        String path = getPath();
        String url = getUrl();

        if (path != null && (path.contains("media"))) {
            if (url != null) {
                Log.i("GsonRequest", "***=> Request URL is: " + url);
            }
        }
    }

```
</details>

-----

## Regression Notes

1. Potential unintended areas of impact
Multiple media requests continue to be excessive

2. What I did to test those areas of impact (or what existing automated tests I relied on)
Manual and unit testing

3. What automated tests I added (or what prevented me from doing so)
Existing tests

-----

## 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): N/A

Excessive Media Requests Delay Critical Post Operations
### Expected behavior
- The app only generates one request to the media endpoint for each featured image of the posts to be fetched.
- Post management operations aren't locked due to fetching featured images of posts.

### Actual behavior
When opening the app for the first time and navigating a site with a long list of posts with featured images, the app generates significantly more requests to the media endpoint than the number of posts. For instance, 40 posts with featured images resulted in 177 requests. This issue is exacerbated by slow network connections, leading to even more requests.

During this period, critical post operations like saving, publishing, and deleting are locked until all the featured image requests are completed. This results in a situation where post management is blocked for several minutes, severely impacting the user experience.

### Steps to reproduce the behavior

**Preparation:**
- Create multiple posts (20 or more) and attach a featured image. This can be done quickly by using the [WPCOM console](https://developer.wordpress.com/docs/api/console/) and the endpoint rest/v1.1/sites/${siteId}/posts/new.

#### Extra requests to the media endpoint
1. On the device/emulator, clear storage of the app, or delete and reinstall the app.
2. Open the app and navigate to the site.
3. Connect the device/emulator to the Charles proxy.
4. Open the app and go to the Post screen.
5. Observe all extra requests to the media endpoint in the Charles proxy.

#### Post manage operations locked
1. On the device/emulator, clear storage of the app, or delete and reinstall the app.
2. Open the app and navigate to the site.
3. Connect the device/emulator to the Charles proxy.
4. Slow down the network connection. This can be achieved by enabling throttling in the Charles proxy.
5. Open the app and go to the Post screen.
6. Edit a post and save it.
7. Observe that the saving operation takes a significant delay.

##### Tested on Samsung Galaxy S20 FE 5G, Android 13, JPAndroid / WPAndroid 25.0-rc-1

Thanks for reporting! 👍 <!-- generated_by_dangermattic -->

After investigating the issue, I noticed the following:

- The request to the media endpoint is related to the featured image of posts shown in the post list ([reference](https://github.com/wordpress-mobile/WordPress-Android/blob/f8db3db3ee1f2ce13c8da6344aaa2353a2bd6131/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListFeaturedImageTracker.kt#L27-L47)).
- The function that fetches the featured image (`getFeaturedImageUrl`) dispatches a request if the media item is not cached. However, it doesn't check if it was requested before. This results in extra requests for the same item as outlined in the issue.
- Going up in the stack trace, I see that all calls are triggered by `PagedListPositionalDataSource.loadInitial` ([reference](https://github.com/wordpress-mobile/WordPress-FluxC-Android/blob/7d5b075778e2c2eb7195e296ed6b8c3df8817356/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PagedListFactory.kt#L40)). However, I saw that this function is called multiple times with the same data set. I don't have the context to understand why this is happening, but I presumed that it has to do with the paging logic of the Android library `androidx.paging`.

_Stack trace of a call to `getFeaturedImageUrl`:_
<img width="1299" alt="Screenshot 2024-05-29 at 12 00 47" src="https://github.com/wordpress-mobile/WordPress-Android/assets/14905380/9c33263c-56d7-4605-9c48-aeb5b384a25e">

diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListEventListener.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListEventListener.kt
index 16d7e3067a53..85e775d67c66 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListEventListener.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListEventListener.kt
@@ -174,8 +174,8 @@ class PostListEventListener(
     @Suppress("unused", "SpreadOperator")
     @Subscribe(threadMode = BACKGROUND)
     fun onMediaChanged(event: OnMediaChanged) {
+        featuredMediaChanged(*event.mediaList.map { it.mediaId }.toLongArray())
         if (!event.isError) {
-            featuredMediaChanged(*event.mediaList.map { it.mediaId }.toLongArray())
             uploadStatusChanged(*event.mediaList.map { it.localPostId }.toIntArray())
         }
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListFeaturedImageTracker.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListFeaturedImageTracker.kt
index d94dd6e0a582..32a9a63b2518 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListFeaturedImageTracker.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListFeaturedImageTracker.kt
@@ -1,6 +1,7 @@
 package org.wordpress.android.ui.posts

 import android.annotation.SuppressLint
+import androidx.annotation.VisibleForTesting
 import org.wordpress.android.fluxc.Dispatcher
 import org.wordpress.android.fluxc.generated.MediaActionBuilder
 import org.wordpress.android.fluxc.model.MediaModel
@@ -22,13 +23,27 @@ class PostListFeaturedImageTracker(private val dispatcher: Dispatcher, private v
     https://github.com/wordpress-mobile/WordPress-Android/issues/11487
      */
     @SuppressLint("UseSparseArrays")
-    private val featuredImageMap = HashMap<Long, String>()
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal val featuredImageMap = HashMap<Long, String>()
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal val ongoingRequests = HashSet<Long>()

     fun getFeaturedImageUrl(site: SiteModel, featuredImageId: Long): String? {
         if (featuredImageId == 0L) {
             return null
         }
-        featuredImageMap[featuredImageId]?.let { return it }
+
+        featuredImageMap[featuredImageId]?.let {
+            return it
+        }
+
+        // Check if a request for this image is already ongoing
+        if (ongoingRequests.contains(featuredImageId)) {
+            // If the request is ongoing, just return. The callback will be invoked upon completion.
+            return null
+        }
+
         mediaStore.getSiteMediaWithId(site, featuredImageId)?.let { media ->
             // This should be a pretty rare case, but some media seems to be missing url
             return if (media.url.isNotBlank()) {
@@ -36,7 +51,11 @@ class PostListFeaturedImageTracker(private val dispatcher: Dispatcher, private v
                 media.url
             } else null
         }
+
         // Media is not in the Store, we need to download it
+        // Mark the request as ongoing
+        ongoingRequests.add(featuredImageId)
+
         val mediaToDownload = MediaModel(
             site.id,
             featuredImageId
@@ -47,6 +66,9 @@ class PostListFeaturedImageTracker(private val dispatcher: Dispatcher, private v
     }

     fun invalidateFeaturedMedia(featuredImageIds: List<Long>) {
-        featuredImageIds.forEach { featuredImageMap.remove(it) }
+        featuredImageIds.forEach {
+            featuredImageMap.remove(it)
+            ongoingRequests.remove(it)
+        }
     }
 }

diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListFeaturedImageTrackerTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListFeaturedImageTrackerTest.kt
new file mode 100644
index 000000000000..a9616c3fd5b1
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListFeaturedImageTrackerTest.kt
@@ -0,0 +1,111 @@
+package org.wordpress.android.ui.posts
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.fluxc.Dispatcher
+import org.wordpress.android.fluxc.model.MediaModel
+import org.wordpress.android.fluxc.model.SiteModel
+import org.wordpress.android.fluxc.store.MediaStore
+import kotlin.test.Test
+
+@Suppress("UNCHECKED_CAST")
+@ExperimentalCoroutinesApi
+class PostListFeaturedImageTrackerTest : BaseUnitTest() {
+    private val dispatcher: Dispatcher = mock()
+    private val mediaStore: MediaStore = mock()
+
+    private lateinit var tracker: PostListFeaturedImageTracker
+
+    private val site = SiteModel().apply { id = 123 }
+
+    @Before
+    fun setup() {
+        tracker = PostListFeaturedImageTracker(dispatcher, mediaStore)
+    }
+
+    @Test
+    fun `given id exists in map, when getFeaturedImageUrl invoked, then return url`() {
+        val imageId = 123L
+        val imageUrl = "https://example.com/image.jpg"
+        tracker.featuredImageMap[imageId] = imageUrl
+
+        val result = tracker.getFeaturedImageUrl(site, imageId)
+
+        assertEquals(imageUrl, result)
+    }
+
+    @Test
+    fun `given id is 0, when getFeaturedImageUrl invoked, then return null`() {
+        val result = tracker.getFeaturedImageUrl(site, 0L)
+
+        assertNull(result)
+    }
+
+    @Test
+    fun `given id not in map and exists in store, when invoked, then return url from media store`() {
+        val imageId = 456L
+        val imageUrl = "https://example.com/image.jpg"
+        val mediaModel = MediaModel(site.id, imageId).apply {
+            url = imageUrl
+        }
+
+        whenever(mediaStore.getSiteMediaWithId(site, imageId)).thenReturn(mediaModel)
+
+        val result = tracker.getFeaturedImageUrl(site, imageId)
+
+        assertEquals(imageUrl, result)
+        assertEquals(imageUrl, tracker.featuredImageMap[imageId])
+    }
+
+    @Test
+    fun `given id not in map or store, when invoked, then return null and dispatch fetch request`() {
+        val imageId = 123L
+
+        whenever(mediaStore.getSiteMediaWithId(site, imageId)).thenReturn(null)
+
+        val result = tracker.getFeaturedImageUrl(site, imageId)
+
+        assertNull(result)
+        verify(dispatcher).dispatch(any())
+        assert(tracker.ongoingRequests.contains(imageId))
+    }
+
+    @Test
+    fun `given request ongoing for id, when invoked, should return null`() {
+        val imageId = 123L
+
+        tracker.ongoingRequests.add(imageId)
+
+        val result = tracker.getFeaturedImageUrl(site, imageId)
+
+        assertNull(result)
+        verify(mediaStore, never()).getSiteMediaWithId(site, imageId)
+        verify(dispatcher, never()).dispatch(any())
+    }
+
+    @Test
+    fun `given id in map and ongoingRequests, when invalidate, then remove id from map and ongoingRequests`() {
+        val imageId1 = 123L
+        val imageId2 = 456L
+
+        tracker.featuredImageMap[imageId1] = "https://example.com/image1.jpg"
+        tracker.featuredImageMap[imageId2] = "https://example.com/image2.jpg"
+        tracker.ongoingRequests.add(imageId1)
+        tracker.ongoingRequests.add(imageId2)
+
+        tracker.invalidateFeaturedMedia(listOf(imageId1, imageId2))
+
+        assertNull(tracker.featuredImageMap[imageId1])
+        assertNull(tracker.featuredImageMap[imageId2])
+        assert(!tracker.ongoingRequests.contains(imageId1))
+        assert(!tracker.ongoingRequests.contains(imageId2))
+    }
+}


[Tags Feed] Implement pull-to-refresh

Fixes #20707

Add pull-to-refresh to Tags Feed, which reloads the user's sites and tags then "restarts" the Tags Feed, fetching newer posts for each of the tags.

This also fixes the wrong post image being shown (it was showing the site avatar instead of the featured image).

-----

## To Test:

1. Open Jetpack
2. Turn on `reader_tags_feed` feature config
3. Go to Reader
4. Go to `Tags` feed
5. Scroll a bit, waiting for the posts to load
6. Wait a few minutes (optional: go to another device/web and follow a new tag)
7. Pull to refresh in the `Tags` feed
8. **Verify** the tags are refreshed
9. **Verify** the posts are loaded for all tags

Note: also **verify** that the post images are now correct.

-----

## Regression Notes

1. Potential unintended areas of impact

    - N/A

10. What I did to test those areas of impact (or what existing automated tests I relied on)

    - N/A

11. What automated tests I added (or what prevented me from doing so)

    - Updated existing tests
    - Added unit tests for the refresh behavior

-----

## 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.
- [x] 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)

[Reader] Implement pull-to-refresh in "Tags" feed

Thanks for reporting! 👍 <!-- generated_by_dangermattic -->

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 5dea6134f1a9..fd66bbe0b089 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
@@ -109,6 +109,10 @@ class ReaderTagsFeedFragment : ViewPagerFragment(R.layout.reader_tag_feed_fragme
                 is ActionEvent.OpenTagPostsFeed -> {
                     subFilterViewModel.setSubfilterFromTag(it.readerTag)
                 }
+
+                ActionEvent.RefreshTagsFeed -> {
+                    subFilterViewModel.updateTagsAndSites()
+                }
             }
         }
     }
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
index 396150e18891..8088fffd39cd 100644
--- 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
@@ -35,7 +35,7 @@ class ReaderTagsFeedUiStateMapper @Inject constructor(
                     ),
                     postTitle = it.title,
                     postExcerpt = it.excerpt,
-                    postImageUrl = it.blogImageUrl,
+                    postImageUrl = it.featuredImage,
                     postNumberOfLikesText = if (it.numLikes > 0) readerUtilsWrapper.getShortLikeLabelText(
                         numLikes = it.numLikes
                     ) else "",
@@ -77,11 +77,13 @@ class ReaderTagsFeedUiStateMapper @Inject constructor(

     fun mapInitialPostsUiState(
         tags: List<ReaderTag>,
+        isRefreshing: Boolean,
         onTagClick: (ReaderTag) -> Unit,
         onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit,
+        onRefresh: () -> Unit,
     ): ReaderTagsFeedViewModel.UiState.Loaded =
         ReaderTagsFeedViewModel.UiState.Loaded(
-            tags.map { tag ->
+            data = tags.map { tag ->
                 ReaderTagsFeedViewModel.TagFeedItem(
                     tagChip = ReaderTagsFeedViewModel.TagChip(
                         tag = tag,
@@ -90,7 +92,9 @@ class ReaderTagsFeedUiStateMapper @Inject constructor(
                     postList = ReaderTagsFeedViewModel.PostList.Initial,
                     onItemEnteredView = onItemEnteredView,
                 )
-            }
+            },
+            isRefreshing = isRefreshing,
+            onRefresh = onRefresh,
         )

     fun mapLoadingTagFeedItem(
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
index 139c0c1179cf..586d9fa5357d 100644
--- 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
@@ -55,11 +55,11 @@ class ReaderTagsFeedViewModel @Inject constructor(
      * [UiState]s: [UiState.Initial], [UiState.Loaded], [UiState.Loading], [UiState.Empty].
      */
     fun start(tags: List<ReaderTag>) {
-        // don't start again if the tags match
-        if (_uiStateFlow.value is UiState.Loaded &&
-            tags == (_uiStateFlow.value as UiState.Loaded).data.map { it.tagChip.tag }
-        ) {
-            return
+        // don't start again if the tags match, unless the user requested a refresh
+        (_uiStateFlow.value as? UiState.Loaded)?.let { loadedState ->
+            if (!loadedState.isRefreshing && tags == loadedState.data.map { it.tagChip.tag }) {
+                return
+            }
         }

         if (tags.isEmpty()) {
@@ -74,7 +74,13 @@ class ReaderTagsFeedViewModel @Inject constructor(

         // Initially add all tags to the list with the posts loading UI
         _uiStateFlow.update {
-            readerTagsFeedUiStateMapper.mapInitialPostsUiState(tags, ::onTagClick, ::onItemEnteredView)
+            readerTagsFeedUiStateMapper.mapInitialPostsUiState(
+                tags,
+                false,
+                ::onTagClick,
+                ::onItemEnteredView,
+                ::onRefresh
+            )
         }
     }

@@ -162,10 +168,18 @@ class ReaderTagsFeedViewModel @Inject constructor(
                     updatedLoadedData[existingIndex] = updatedItem
                 }

-            UiState.Loaded(updatedLoadedData)
+            (uiState as? UiState.Loaded)?.copy(data = updatedLoadedData) ?: UiState.Loaded(updatedLoadedData)
         }
     }

+    @VisibleForTesting
+    fun onRefresh() {
+        _uiStateFlow.update {
+            (it as? UiState.Loaded)?.copy(isRefreshing = true) ?: it
+        }
+        _actionEvents.value = ActionEvent.RefreshTagsFeed
+    }
+
     @VisibleForTesting
     fun onItemEnteredView(item: TagFeedItem) {
         if (item.postList != PostList.Initial) {
@@ -286,15 +300,15 @@ class ReaderTagsFeedViewModel @Inject constructor(

     private fun findTagFeedItemToUpdate(uiState: UiState.Loaded, postItemToUpdate: TagsFeedPostItem) =
         uiState.data.firstOrNull { tagFeedItem ->
-        tagFeedItem.postList is PostList.Loaded && tagFeedItem.postList.items.firstOrNull {
-            it.postId == postItemToUpdate.postId && it.blogId == postItemToUpdate.blogId
-        } != null
-    }
+            tagFeedItem.postList is PostList.Loaded && tagFeedItem.postList.items.firstOrNull {
+                it.postId == postItemToUpdate.postId && it.blogId == postItemToUpdate.blogId
+            } != null
+        }

     private fun likePostRemote(postItem: TagsFeedPostItem, isPostLikedUpdated: Boolean) {
         launch {
-            findPost(postItem.postId, postItem.blogId)?.let {
-                postLikeUseCase.perform(it, !it.isLikedByCurrentUser, ReaderTracker.SOURCE_TAGS_FEED).collect {
+            findPost(postItem.postId, postItem.blogId)?.let { post ->
+                postLikeUseCase.perform(post, !post.isLikedByCurrentUser, ReaderTracker.SOURCE_TAGS_FEED).collect {
                     when (it) {
                         is PostLikeUseCase.PostLikeState.Success -> {
                             // Re-enable like button without changing the current post item UI.
@@ -348,11 +362,18 @@ class ReaderTagsFeedViewModel @Inject constructor(

     sealed class ActionEvent {
         data class OpenTagPostsFeed(val readerTag: ReaderTag) : ActionEvent()
+
+        data object RefreshTagsFeed : ActionEvent()
     }

     sealed class UiState {
         data object Initial : UiState()
-        data class Loaded(val data: List<TagFeedItem>) : UiState()
+
+        data class Loaded(
+            val data: List<TagFeedItem>,
+            val isRefreshing: Boolean = false,
+            val onRefresh: () -> Unit = {},
+        ) : UiState()

         data object Loading : UiState()

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 6a89eb1584ed..b917a579862c 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
@@ -23,7 +23,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.Button
 import androidx.compose.material.ButtonDefaults
 import androidx.compose.material.ContentAlpha
+import androidx.compose.material.ExperimentalMaterialApi
 import androidx.compose.material.MaterialTheme
+import androidx.compose.material.pullrefresh.PullRefreshIndicator
+import androidx.compose.material.pullrefresh.pullRefresh
+import androidx.compose.material.pullrefresh.rememberPullRefreshState
 import androidx.compose.material.ripple.rememberRipple
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Text
@@ -58,6 +62,8 @@ import org.wordpress.android.ui.reader.views.compose.filter.ReaderFilterChip
 import org.wordpress.android.ui.utils.UiString
 import org.wordpress.android.util.AppLog

+private const val LOADING_POSTS_COUNT = 5
+
 @Composable
 fun ReaderTagsFeed(uiState: UiState) {
     Box(
@@ -77,46 +83,66 @@ fun ReaderTagsFeed(uiState: UiState) {
     }
 }

+@OptIn(ExperimentalMaterialApi::class)
 @Composable
 private fun Loaded(uiState: UiState.Loaded) {
-    LazyColumn(
+    val pullRefreshState = rememberPullRefreshState(
+        refreshing = uiState.isRefreshing,
+        onRefresh = {
+            uiState.onRefresh()
+        }
+    )
+
+    Box(
         modifier = Modifier
-            .fillMaxSize(),
+            .fillMaxSize()
+            .pullRefresh(state = pullRefreshState),
     ) {
-        items(
-            items = uiState.data,
-        ) { item ->
-            val tagChip = item.tagChip
-            val postList = item.postList
+        LazyColumn(
+            modifier = Modifier
+                .fillMaxSize(),
+        ) {
+            items(
+                items = uiState.data,
+            ) { item ->
+                val tagChip = item.tagChip
+                val postList = item.postList

-            LaunchedEffect(Unit) {
-                item.onEnteredView()
-            }
+                LaunchedEffect(item.postList) {
+                    item.onEnteredView()
+                }

-            val backgroundColor = if (isSystemInDarkTheme()) {
-                AppColor.White.copy(alpha = 0.12F)
-            } else {
-                AppColor.Black.copy(alpha = 0.08F)
-            }
-            Spacer(modifier = Modifier.height(Margin.Large.value))
-            // Tag chip UI
-            ReaderFilterChip(
-                modifier = Modifier.padding(
-                    start = Margin.Large.value,
-                ),
-                text = UiString.UiStringText(tagChip.tag.tagTitle),
-                onClick = { tagChip.onTagClick(tagChip.tag) },
-                height = 36.dp,
-            )
-            Spacer(modifier = Modifier.height(Margin.Large.value))
-            // Posts list UI
-            when (postList) {
-                is PostList.Initial, is PostList.Loading -> PostListLoading()
-                is PostList.Loaded -> PostListLoaded(postList, tagChip, backgroundColor)
-                is PostList.Error -> PostListError(backgroundColor, tagChip, postList)
+                val backgroundColor = if (isSystemInDarkTheme()) {
+                    AppColor.White.copy(alpha = 0.12F)
+                } else {
+                    AppColor.Black.copy(alpha = 0.08F)
+                }
+                Spacer(modifier = Modifier.height(Margin.Large.value))
+                // Tag chip UI
+                ReaderFilterChip(
+                    modifier = Modifier.padding(
+                        start = Margin.Large.value,
+                    ),
+                    text = UiString.UiStringText(tagChip.tag.tagTitle),
+                    onClick = { tagChip.onTagClick(tagChip.tag) },
+                    height = 36.dp,
+                )
+                Spacer(modifier = Modifier.height(Margin.Large.value))
+                // Posts list UI
+                when (postList) {
+                    is PostList.Initial, is PostList.Loading -> PostListLoading()
+                    is PostList.Loaded -> PostListLoaded(postList, tagChip, backgroundColor)
+                    is PostList.Error -> PostListError(backgroundColor, tagChip, postList)
+                }
+                Spacer(modifier = Modifier.height(Margin.ExtraExtraMediumLarge.value))
             }
-            Spacer(modifier = Modifier.height(Margin.ExtraExtraMediumLarge.value))
         }
+
+        PullRefreshIndicator(
+            refreshing = uiState.isRefreshing,
+            state = pullRefreshState,
+            modifier = Modifier.align(Alignment.TopCenter),
+        )
     }
 }

@@ -148,14 +174,12 @@ private fun Loading() {
                 LazyRow(
                     modifier = Modifier
                         .fillMaxWidth(),
-                    contentPadding = PaddingValues(horizontal = 12.dp),
                     userScrollEnabled = false,
+                    horizontalArrangement = Arrangement.spacedBy(Margin.Large.value),
+                    contentPadding = PaddingValues(horizontal = Margin.Large.value),
                 ) {
-                    item {
+                    items(LOADING_POSTS_COUNT) {
                         ReaderTagsFeedPostListItemLoading()
-                        Spacer(Modifier.width(12.dp))
-                        ReaderTagsFeedPostListItemLoading()
-                        Spacer(Modifier.width(12.dp))
                     }
                 }
             }
@@ -240,16 +264,14 @@ private fun PostListLoading() {
         modifier = Modifier
             .fillMaxWidth(),
         userScrollEnabled = false,
+        horizontalArrangement = Arrangement.spacedBy(Margin.ExtraMediumLarge.value),
         contentPadding = PaddingValues(
             start = Margin.Large.value,
             end = Margin.Large.value
         ),
     ) {
-        item {
-            ReaderTagsFeedPostListItemLoading()
-            Spacer(Modifier.width(Margin.ExtraMediumLarge.value))
+        items(LOADING_POSTS_COUNT) {
             ReaderTagsFeedPostListItemLoading()
-            Spacer(Modifier.width(Margin.ExtraMediumLarge.value))
         }
     }
 }
@@ -272,9 +294,9 @@ private fun PostListLoaded(
         items(
             items = postList.items,
         ) { postItem ->
-                ReaderTagsFeedPostListItem(
-                    item = postItem
-                )
+            ReaderTagsFeedPostListItem(
+                item = postItem
+            )
         }
         item {
             val baseColor = if (isSystemInDarkTheme()) AppColor.White else AppColor.Black

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 0db161ad16f8..84e494c2eb1d 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
@@ -20,6 +20,7 @@ import org.mockito.kotlin.verifyNoInteractions
 import org.mockito.kotlin.whenever
 import org.wordpress.android.BaseUnitTest
 import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper
+import org.wordpress.android.getOrAwaitValue
 import org.wordpress.android.models.ReaderPost
 import org.wordpress.android.models.ReaderPostList
 import org.wordpress.android.models.ReaderTag
@@ -288,11 +289,16 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
             posts2
         }
         mockMapInitialTagFeedItems()
+        mockMapLoadingTagFeedItems()
         mockMapLoadedTagFeedItems()

         // When
         viewModel.start(listOf(tag1, tag2))
         advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag1))
+        advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag2))
+        advanceUntilIdle()
         val firstCollectedStates = collectedUiStates.toList()
         Mockito.clearInvocations(readerPostRepository)

@@ -304,6 +310,54 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         verifyNoInteractions(readerPostRepository)
     }

+    @Suppress("LongMethod")
+    @Test
+    fun `given tags fetched, when start again refreshing, then move back to initial state`() = testCollectingUiStates {
+        // Given
+        val tag1 = ReaderTestUtils.createTag("tag1")
+        val tag2 = ReaderTestUtils.createTag("tag2")
+        val posts1 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
+        val posts2 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer {
+            delay(100)
+            posts1
+        }
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer {
+            delay(200)
+            posts2
+        }
+        mockMapInitialTagFeedItems()
+        mockMapLoadingTagFeedItems()
+        mockMapLoadedTagFeedItems()
+
+        // When
+        viewModel.start(listOf(tag1, tag2))
+        advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag1))
+        advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag2))
+        advanceUntilIdle()
+
+        viewModel.onRefresh()
+
+        // Then
+        viewModel.start(listOf(tag1, tag2))
+        advanceUntilIdle()
+
+        val loadedState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.Loaded
+        assertThat(loadedState.data).isEqualTo(
+            listOf(
+                    getInitialTagFeedItem(tag1),
+                    getInitialTagFeedItem(tag2)
+                )
+            )
+        assertThat(loadedState.isRefreshing).isFalse()
+    }
+
     @Suppress("LongMethod")
     @Test
     fun `given no tags requested, when start, then UI state should update properly`() = testCollectingUiStates {
@@ -318,6 +372,83 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         assertThat(collectedUiStates).last().isInstanceOf(ReaderTagsFeedViewModel.UiState.Empty::class.java)
     }

+    @Suppress("LongMethod")
+    @Test
+    fun `given tags fetched, when refreshing, then update isRefreshing status`() = testCollectingUiStates {
+        // Given
+        val tag1 = ReaderTestUtils.createTag("tag1")
+        val tag2 = ReaderTestUtils.createTag("tag2")
+        val posts1 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
+        val posts2 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer {
+            delay(100)
+            posts1
+        }
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer {
+            delay(200)
+            posts2
+        }
+        mockMapInitialTagFeedItems()
+        mockMapLoadingTagFeedItems()
+        mockMapLoadedTagFeedItems()
+
+        // When
+        viewModel.start(listOf(tag1, tag2))
+        advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag1))
+        advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag2))
+        advanceUntilIdle()
+
+        // Then
+        viewModel.onRefresh()
+
+        val loadedState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.Loaded
+        assertThat(loadedState.isRefreshing).isTrue()
+    }
+
+    @Suppress("LongMethod")
+    @Test
+    fun `given tags fetched, when refreshing, then RefreshTagsFeed action is posted`() = testCollectingUiStates {
+        // Given
+        val tag1 = ReaderTestUtils.createTag("tag1")
+        val tag2 = ReaderTestUtils.createTag("tag2")
+        val posts1 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
+        val posts2 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer {
+            delay(100)
+            posts1
+        }
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer {
+            delay(200)
+            posts2
+        }
+        mockMapInitialTagFeedItems()
+        mockMapLoadingTagFeedItems()
+        mockMapLoadedTagFeedItems()
+
+        // When
+        viewModel.start(listOf(tag1, tag2))
+        advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag1))
+        advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag2))
+        advanceUntilIdle()
+
+        // Then
+        viewModel.onRefresh()
+
+        val action = viewModel.actionEvents.getOrAwaitValue()
+        assertThat(action).isEqualTo(ActionEvent.RefreshTagsFeed)
+    }
     @Test
     fun `Should update UI immediately when like button is tapped`() = testCollectingUiStates {
         // Given
@@ -406,7 +537,7 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
     }

     private fun mockMapInitialTagFeedItems() {
-        whenever(readerTagsFeedUiStateMapper.mapInitialPostsUiState(any(), any(), any()))
+        whenever(readerTagsFeedUiStateMapper.mapInitialPostsUiState(any(), any(), any(), any(), any()))
             .thenAnswer {
                 val tags = it.getArgument<List<ReaderTag>>(0)
                 ReaderTagsFeedViewModel.UiState.Loaded(
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
index d5a4309d4086..49f11f698f2e 100644
--- 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
@@ -35,7 +35,7 @@ class ReaderTagsFeedUiStateMapperTest : BaseUnitTest() {
             blogName = "Name"
             title = "Title"
             excerpt = "Excerpt"
-            blogImageUrl = "url"
+            featuredImage = "url"
             numLikes = 5
             numReplies = 10
             isLikedByCurrentUser = true
@@ -95,7 +95,7 @@ class ReaderTagsFeedUiStateMapperTest : BaseUnitTest() {
                         postDateLine = dateLine,
                         postTitle = readerPost.title,
                         postExcerpt = readerPost.excerpt,
-                        postImageUrl = readerPost.blogImageUrl,
+                        postImageUrl = readerPost.featuredImage,
                         postNumberOfLikesText = numberLikesText,
                         postNumberOfCommentsText = numberCommentsText,
                         isPostLiked = readerPost.isLikedByCurrentUser,
@@ -157,6 +157,7 @@ class ReaderTagsFeedUiStateMapperTest : BaseUnitTest() {
         // Given
         val onTagClick: (ReaderTag) -> Unit = {}
         val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {}
+        val onRefresh: () -> Unit = {}
         val tag1 = ReaderTag(
             "tag",
             "tag",
@@ -176,8 +177,10 @@ class ReaderTagsFeedUiStateMapperTest : BaseUnitTest() {
         // When
         val actual = classToTest.mapInitialPostsUiState(
             tags = tags,
+            isRefreshing = true,
             onTagClick = onTagClick,
             onItemEnteredView = onItemEnteredView,
+            onRefresh = onRefresh,
         )

         // Then
@@ -199,7 +202,9 @@ class ReaderTagsFeedUiStateMapperTest : BaseUnitTest() {
                     postList = ReaderTagsFeedViewModel.PostList.Initial,
                     onItemEnteredView = onItemEnteredView,
                 )
-            )
+            ),
+            isRefreshing = true,
+            onRefresh = onRefresh,
         )
         assertEquals(expected, actual)
     }