[Reader] Add tracks for text highlight/copy in Post Details body

Fixes #20888

Add tracks to text highlight and copy in Post Details body WebView. For that, I used the same javascript code used on iOS (https://github.com/wordpress-mobile/WordPress-iOS/pull/23251/files#diff-0c87a62d20f4b5ede4c7274c4e990d865a633b92dfe70d702672ee27191613d9) to be able to listen to the appropriate events and send a message to the Java/Kotlin code.

-----

## To Test:

1. Open Jetpack
2. Make sure data collection is enabled in `App Settings -> Privacy`
3. Go to Reader
4. Go to any post
5. Select some text
6. **Verify** the `reader_article_text_highlighted` event is tracked (use logcat)
7. Tap on `Copy` in the context menu
8. **Verify** the `reader_article_text_copied` event is tracked

Note: the highlight event has a slight delay (1 second) due to a debounce mechanism. This was done to make sure the event is sent usually after the user selects and adjusts the selection and avoid sending multiple events for each selection change.

-----

## 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)

    - Added unit tests for tracking

-----

## 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] Track how many people select text content in Post Details
In order to move forward with the content annotating proposal, we need to start tracking how many people select text content in the Reader and how many copy it. Ref: p1716377347554169-slack-C06D0RNJG11

iOS issue: https://github.com/wordpress-mobile/WordPress-iOS/issues/23250

Add the following events:
- [x] `reader_article_text_highlighted`
- [x] `reader_article_text_copied`
- [ ] `reader_comment_text_highlighted`
- [ ] `reader_comment_text_copied`

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

diff --git a/WordPress/src/jetpack/assets/reader_text_events.js b/WordPress/src/jetpack/assets/reader_text_events.js
new file mode 100644
index 000000000000..68cc132b5eeb
--- /dev/null
+++ b/WordPress/src/jetpack/assets/reader_text_events.js
@@ -0,0 +1,26 @@
+/*
+ * wvHandler is the name of the message handler in the webview, added via createJsObject inside
+ * ReaderPostRenderer. It handles the `postMessage` calls from the WebView.
+ */
+
+function debounce(fn, timeout) {
+    let timer;
+    return () => {
+        clearTimeout(timer);
+        timer = setTimeout(fn, timeout);
+    }
+}
+
+const textHighlighted = debounce(
+    () => wvHandler.postMessage("articleTextHighlighted"),
+    1000
+);
+
+document.addEventListener('selectionchange', function(event) {
+    const selection = document.getSelection().toString();
+    if (selection.length > 0) {
+        textHighlighted();
+    }
+});
+
+document.addEventListener('copy', event => wvHandler.postMessage("articleTextCopied"));
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt
index e141e8cec720..6b69d3320af1 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt
@@ -94,6 +94,7 @@ import org.wordpress.android.ui.pages.SnackbarMessageHolder
 import org.wordpress.android.ui.reader.ReaderActivityLauncher.OpenUrlType
 import org.wordpress.android.ui.reader.ReaderActivityLauncher.PhotoViewerOption
 import org.wordpress.android.ui.reader.ReaderPostPagerActivity.DirectOperation
+import org.wordpress.android.ui.reader.ReaderPostRenderer.ReaderPostMessageListener
 import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType
 import org.wordpress.android.ui.reader.actions.ReaderActions
 import org.wordpress.android.ui.reader.actions.ReaderPostActions
@@ -1605,7 +1606,17 @@ class ReaderPostDetailFragment : ViewPagerFragment(),
             viewModel.post,
             readerCssProvider,
             getReadingPreferences()
-        )
+        ).also {
+            it.setPostMessageListener(object : ReaderPostMessageListener {
+                override fun onArticleTextCopied() {
+                    viewModel.onArticleTextCopied()
+                }
+
+                override fun onArticleTextHighlighted() {
+                    viewModel.onArticleTextHighlighted()
+                }
+            })
+        }

         // if the post is from private atomic site postpone render until we have a special access cookie
         if (post.isPrivateAtomic && privateAtomicCookie.isCookieRefreshRequired()) {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostRenderer.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostRenderer.java
index 2ac457f79167..c7157a096ebe 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostRenderer.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostRenderer.java
@@ -3,14 +3,17 @@
 import android.annotation.SuppressLint;
 import android.net.Uri;
 import android.os.Handler;
+import android.webkit.WebView;

 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;

 import org.jsoup.Jsoup;
 import org.wordpress.android.R;
 import org.wordpress.android.WordPress;
 import org.wordpress.android.models.ReaderPost;
 import org.wordpress.android.models.ReaderPostDiscoverData;
+import org.wordpress.android.support.JsObjectKt;
 import org.wordpress.android.ui.reader.models.ReaderReadingPreferences;
 import org.wordpress.android.ui.reader.models.ReaderReadingPreferences.ThemeValues;
 import org.wordpress.android.ui.reader.utils.ImageSizeMap;
@@ -44,6 +47,7 @@
  * http://developer.android.com/guide/webapps/targeting.html
  */
 public class ReaderPostRenderer {
+    private static final String JAVASCRIPT_MESSAGE_HANDLER = "wvHandler";
     private final ReaderResourceVars mResourceVars;
     private final ReaderPost mPost;
     private final int mMinFullSizeWidthDp;
@@ -56,6 +60,8 @@ public class ReaderPostRenderer {
     private ReaderCssProvider mCssProvider;
     private ReaderReadingPreferences mReadingPreferences;
     private ReaderReadingPreferences.ThemeValues mReadingPreferencesTheme;
+    @Nullable
+    private ReaderPostMessageListener mPostMessageListener = null;

     @SuppressLint("SetJavaScriptEnabled")
     public ReaderPostRenderer(ReaderWebView webView, ReaderPost post, ReaderCssProvider cssProvider,
@@ -80,6 +86,7 @@ public ReaderPostRenderer(ReaderWebView webView, ReaderPost post, ReaderCssProvi
         // enable JavaScript in the webView, otherwise videos and other embedded content won't
         // work - note that the content is scrubbed on the backend so this is considered safe
         webView.getSettings().setJavaScriptEnabled(true);
+        setWebViewMessageHandler(webView);
     }

     public void beginRender() {
@@ -543,6 +550,7 @@ private String formatPostContentForWebView(final String content, final Set<Strin
         }

         sbHtml.append("")
+              .append("")
               .append(contentCustomised)
               .append("");

@@ -635,4 +643,39 @@ private String getContentTextProperties() {
                + "font-weight: 400; "
                + "font-size: " + mReadingPreferences.getFontSize().getValue() + "px; ";
     }
+
+    private void setWebViewMessageHandler(@NonNull WebView webView) {
+        Set<String> allowedOrigins = new HashSet<>();
+        allowedOrigins.add("*");
+
+        JsObjectKt.createJsObject(
+                webView, JAVASCRIPT_MESSAGE_HANDLER, allowedOrigins,
+                (message) -> {
+                    if (mPostMessageListener == null) {
+                        return null;
+                    }
+
+                    switch (message) {
+                        case ReaderPostMessageListener.MSG_ARTICLE_TEXT_COPIED:
+                            mPostMessageListener.onArticleTextCopied();
+                            break;
+                        case ReaderPostMessageListener.MSG_ARTICLE_TEXT_HIGHLIGHTED:
+                            mPostMessageListener.onArticleTextHighlighted();
+                            break;
+                    }
+                    return null;
+                });
+    }
+
+    void setPostMessageListener(@Nullable ReaderPostMessageListener listener) {
+        mPostMessageListener = listener;
+    }
+
+    interface ReaderPostMessageListener {
+        String MSG_ARTICLE_TEXT_COPIED = "articleTextCopied";
+        String MSG_ARTICLE_TEXT_HIGHLIGHTED = "articleTextHighlighted";
+
+        void onArticleTextCopied();
+        void onArticleTextHighlighted();
+    }
 }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt
index 05aeae289c44..884564b2d79d 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt
@@ -13,7 +13,6 @@ import kotlinx.coroutines.withContext
 import org.greenrobot.eventbus.Subscribe
 import org.greenrobot.eventbus.ThreadMode.MAIN
 import org.wordpress.android.R
-import org.wordpress.android.analytics.AnalyticsTracker
 import org.wordpress.android.analytics.AnalyticsTracker.Stat
 import org.wordpress.android.datasets.wrappers.ReaderCommentTableWrapper
 import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper
@@ -587,9 +586,9 @@ class ReaderPostDetailViewModel @Inject constructor(

     private fun trackRelatedPostClickAction(postId: Long, blogId: Long, isGlobal: Boolean) {
         val stat = if (isGlobal) {
-            AnalyticsTracker.Stat.READER_GLOBAL_RELATED_POST_CLICKED
+            Stat.READER_GLOBAL_RELATED_POST_CLICKED
         } else {
-            AnalyticsTracker.Stat.READER_LOCAL_RELATED_POST_CLICKED
+            Stat.READER_LOCAL_RELATED_POST_CLICKED
         }
         readerTracker.trackPost(stat, findPost(blogId, postId))
     }
@@ -625,7 +624,7 @@ class ReaderPostDetailViewModel @Inject constructor(

     private fun updatePostDetailsUi() {
         post?.let {
-            readerTracker.trackPost(AnalyticsTracker.Stat.READER_ARTICLE_RENDERED, it)
+            readerTracker.trackPost(Stat.READER_ARTICLE_RENDERED, it)
             _navigationEvents.postValue(Event(ShowPostInWebView(it)))
             _uiState.value = convertPostToUiState(it)
         }
@@ -687,9 +686,9 @@ class ReaderPostDetailViewModel @Inject constructor(

     private fun trackNotAuthorisedState() {
         if (shouldOfferSignIn) {
-            post?.let { readerTracker.trackPost(AnalyticsTracker.Stat.READER_WPCOM_SIGN_IN_NEEDED, it) }
+            post?.let { readerTracker.trackPost(Stat.READER_WPCOM_SIGN_IN_NEEDED, it) }
         }
-        post?.let { readerTracker.trackPost(AnalyticsTracker.Stat.READER_USER_UNAUTHORIZED, it) }
+        post?.let { readerTracker.trackPost(Stat.READER_USER_UNAUTHORIZED, it) }
     }

     private fun getNotAuthorisedErrorMessageRes() = if (!shouldOfferSignIn) {
@@ -1012,6 +1011,14 @@ class ReaderPostDetailViewModel @Inject constructor(
         }
     }

+    fun onArticleTextCopied() {
+        readerTracker.track(Stat.READER_ARTICLE_TEXT_COPIED)
+    }
+
+    fun onArticleTextHighlighted() {
+        readerTracker.track(Stat.READER_ARTICLE_TEXT_HIGHLIGHTED)
+    }
+
     companion object {
         @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
         const val MAX_NUM_LIKES_FACES_WITH_SELF = 6
diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java
index 83135f79892e..699f1078283e 100644
--- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java
+++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java
@@ -42,6 +42,10 @@ public enum Stat {
         READER_ARTICLE_DETAIL_UNLIKED,
         READER_ARTICLE_RENDERED,
         READER_ARTICLE_VISITED,
+        READER_ARTICLE_TEXT_HIGHLIGHTED,
+        READER_ARTICLE_TEXT_COPIED,
+        READER_COMMENT_TEXT_HIGHLIGHTED,
+        READER_COMMENT_TEXT_COPIED,
         READER_USER_BLOCKED,
         READER_BLOG_BLOCKED,
         READER_BLOG_FOLLOWED("reader_site_followed"),

diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt
index a2a7ea471207..ee39aae42d1c 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt
@@ -27,6 +27,7 @@ import org.mockito.kotlin.verify
 import org.mockito.kotlin.whenever
 import org.wordpress.android.BaseUnitTest
 import org.wordpress.android.R
+import org.wordpress.android.analytics.AnalyticsTracker
 import org.wordpress.android.datasets.wrappers.ReaderCommentTableWrapper
 import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper
 import org.wordpress.android.fluxc.model.AccountModel
@@ -1100,6 +1101,18 @@ class ReaderPostDetailViewModelTest : BaseUnitTest() {
         )
     }

+    @Test
+    fun `onArticleTextCopied tracks the reader_article_text_copied event`() {
+        viewModel.onArticleTextCopied()
+        verify(readerTracker).track(AnalyticsTracker.Stat.READER_ARTICLE_TEXT_COPIED)
+    }
+
+    @Test
+    fun `onArticleTextHighlighted tracks the reader_article_text_highlighted event`() {
+        viewModel.onArticleTextHighlighted()
+        verify(readerTracker).track(AnalyticsTracker.Stat.READER_ARTICLE_TEXT_HIGHLIGHTED)
+    }
+
     private fun <T> testWithoutLocalPost(block: suspend CoroutineScope.() -> T) {
         test {
             whenever(readerGetPostUseCase.get(any(), any(), any())).thenReturn(Pair(null, false))


[Tags Feed] Update feed tags as Diff, keeping existing tag content

Fixes #20811

When a new `onTagsChanged` is called and the current UI State is `Loaded`, let's just update the tags list and keep any content we already fetched before, by:
- Removing rows from tags that are not in the new call
- Adding rows for tags in the new call in the appropriate position, in the Initial state
- Using modifier to animate content to the correct place, using tag slug as key

-----

## To Test:
1. Open Jetpack
2. Make sure `reader_tags_feed` Feature Config is ON
3. Go to Reader
4. Go to `Your Tags` feed
5. Scroll horizontally in one of the tags and tap `More from`
6. Unfollow the tag
7. Go back
8. **Verify** the tag is removed from the list but the existing tags don't reload

You can also test adding a new tag by:
1. Go to `Your Tags` feed
2. Scroll horizontally in one of the tags and tap `More from`
2. Tap one of the posts
3. Tap a tag from the post that you don't follow yet
4. Follow that tag
5. Hit back until you're back in the `Your Tags` feed
6. **Verify** the new tag appears in the appropriate position and existing tags kept their state

-----

## 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)

    - Added unit tests for new scenarios
    - Update unit tests for existing scenarios

-----

## 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] Update only tags that changed instead of the whole list in "Your Tags" feed
Currently we're updating the whole "Your Tags" list when the subscribed status of one tag changes. We should only update the subscribed/unsubscribed tag item instead  ([see here](https://github.com/wordpress-mobile/WordPress-Android/pull/20801#issuecomment-2108999829))

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 cfac4b72a7b1..015a1433aec1 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
@@ -94,13 +94,10 @@ class ReaderTagsFeedUiStateMapper @Inject constructor(
     ): ReaderTagsFeedViewModel.UiState.Loaded =
         ReaderTagsFeedViewModel.UiState.Loaded(
             data = tags.map { tag ->
-                ReaderTagsFeedViewModel.TagFeedItem(
-                    tagChip = ReaderTagsFeedViewModel.TagChip(
-                        tag = tag,
-                        onTagChipClick = onTagChipClick,
-                        onMoreFromTagClick = onMoreFromTagClick,
-                    ),
-                    postList = ReaderTagsFeedViewModel.PostList.Initial,
+                mapInitialTagFeedItem(
+                    tag = tag,
+                    onTagChipClick = onTagChipClick,
+                    onMoreFromTagClick = onMoreFromTagClick,
                     onItemEnteredView = onItemEnteredView,
                 )
             },
@@ -108,6 +105,22 @@ class ReaderTagsFeedUiStateMapper @Inject constructor(
             onRefresh = onRefresh,
         )

+    fun mapInitialTagFeedItem(
+        tag: ReaderTag,
+        onTagChipClick: (ReaderTag) -> Unit,
+        onMoreFromTagClick: (ReaderTag) -> Unit,
+        onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit,
+    ): ReaderTagsFeedViewModel.TagFeedItem =
+        ReaderTagsFeedViewModel.TagFeedItem(
+            tagChip = ReaderTagsFeedViewModel.TagChip(
+                tag = tag,
+                onTagChipClick = onTagChipClick,
+                onMoreFromTagClick = onMoreFromTagClick,
+            ),
+            postList = ReaderTagsFeedViewModel.PostList.Initial,
+            onItemEnteredView = onItemEnteredView,
+        )
+
     fun mapLoadingTagFeedItem(
         tag: ReaderTag,
         onTagChipClick: (ReaderTag) -> Unit,
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 2a0344f7ef56..8ccfa74dd5fa 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
@@ -88,34 +88,41 @@ class ReaderTagsFeedViewModel @Inject constructor(
         }
     }

-    /**
-     * 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 onTagsChanged(tags: List<ReaderTag>) {
-        // don't fetch tags 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
+    fun onTagsChanged(tags: List<ReaderTag>) = _uiStateFlow.update { currentState ->
+        when {
+            tags.isEmpty() -> {
+                UiState.Empty(::onOpenTagsListClick)
             }
-        }

-        if (tags.isEmpty()) {
-            _uiStateFlow.value = UiState.Empty(::onOpenTagsListClick)
-            return
-        }
+            currentState is UiState.Loaded -> {
+                val currentTags = currentState.data.map { it.tagChip.tag }
+                if (currentState.isRefreshing) {
+                    readerTagsFeedUiStateMapper.mapInitialPostsUiState(
+                        tags,
+                        false,
+                        ::onTagChipClick,
+                        ::onMoreFromTagClick,
+                        ::onItemEnteredView,
+                        ::onRefresh
+                    )
+                } else if (currentTags != tags) {
+                    updateLoadedStateWithTags(currentState, tags)
+                } else {
+                    currentState
+                }
+            }

-        // Add tags to the list with the posts loading UI
-        _uiStateFlow.update {
-            readerTagsFeedUiStateMapper.mapInitialPostsUiState(
-                tags,
-                false,
-                ::onTagChipClick,
-                ::onMoreFromTagClick,
-                ::onItemEnteredView,
-                ::onRefresh
-            )
+            else -> {
+                // Add tags to the list with the posts initial/loading UI
+                readerTagsFeedUiStateMapper.mapInitialPostsUiState(
+                    tags,
+                    false,
+                    ::onTagChipClick,
+                    ::onMoreFromTagClick,
+                    ::onItemEnteredView,
+                    ::onRefresh
+                )
+            }
         }
     }

@@ -139,6 +146,19 @@ class ReaderTagsFeedViewModel @Inject constructor(
         }
     }

+    private fun updateLoadedStateWithTags(state: UiState.Loaded, tags: List<ReaderTag>): UiState.Loaded {
+        val currentTagsMap = state.data.associateBy { it.tagChip.tag.tagSlug }
+        val updatedData = tags.map { tag ->
+            currentTagsMap[tag.tagSlug] ?: readerTagsFeedUiStateMapper.mapInitialTagFeedItem(
+                tag = tag,
+                onTagChipClick = ::onTagChipClick,
+                onMoreFromTagClick = ::onMoreFromTagClick,
+                onItemEnteredView = ::onItemEnteredView,
+            )
+        }
+        return state.copy(data = updatedData)
+    }
+
     private fun onNoConnectionRetryClick() {
         _uiStateFlow.value = UiState.Loading
         if (networkUtilsWrapper.isNetworkAvailable()) {
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 789bf55173d7..1394c2d971ce 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
@@ -1,6 +1,7 @@
 package org.wordpress.android.ui.reader.views.compose.tagsfeed

 import android.content.res.Configuration
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -87,7 +88,7 @@ fun ReaderTagsFeed(uiState: UiState) {
     }
 }

-@OptIn(ExperimentalMaterialApi::class)
+@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
 @Composable
 private fun Loaded(uiState: UiState.Loaded) {
     val pullRefreshState = rememberPullRefreshState(
@@ -108,6 +109,7 @@ private fun Loaded(uiState: UiState.Loaded) {
         ) {
             items(
                 items = uiState.data,
+                key = { it.tagChip.tag.tagSlug }
             ) { item ->
                 val tagChip = item.tagChip
                 val postList = item.postList
@@ -121,24 +123,33 @@ private fun Loaded(uiState: UiState.Loaded) {
                 } 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.onTagChipClick(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(postList, tagChip, backgroundColor)
+
+                Column(
+                    modifier = Modifier
+                        .animateItemPlacement()
+                        .fillMaxWidth()
+                        .padding(
+                            top = Margin.Large.value,
+                            bottom = Margin.ExtraExtraMediumLarge.value,
+                        )
+                ) {
+                    // Tag chip UI
+                    ReaderFilterChip(
+                        modifier = Modifier.padding(
+                            start = Margin.Large.value,
+                        ),
+                        text = UiString.UiStringText(tagChip.tag.tagTitle),
+                        onClick = { tagChip.onTagChipClick(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(postList, tagChip, backgroundColor)
+                    }
                 }
-                Spacer(modifier = Modifier.height(Margin.ExtraExtraMediumLarge.value))
             }
         }

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 83baaa4a6c28..992017556433 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
@@ -182,7 +182,6 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         )
     }

-    @Suppress("LongMethod")
     @Test
     fun `given valid tags, when loaded, then UI state should update properly`() = testCollectingUiStates {
         // Given
@@ -225,7 +224,6 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         )
     }

-    @Suppress("LongMethod")
     @Test
     fun `given valid and invalid tags, when loaded, then UI state should update properly`() = testCollectingUiStates {
         // Given
@@ -342,9 +340,8 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         assertIs<Event<ReaderNavigationEvents.ShowBlogPreview>>(readerNavigationEvents.first())
     }

-    @Suppress("LongMethod")
     @Test
-    fun `given tags fetched, when start again, then nothing happens`() = testCollectingUiStates {
+    fun `given tags fetched, when onTagsChanged again, then nothing happens`() = testCollectingUiStates {
         // Given
         val tag1 = ReaderTestUtils.createTag("tag1")
         val tag2 = ReaderTestUtils.createTag("tag2")
@@ -384,19 +381,23 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         verifyNoInteractions(readerPostRepository)
     }

-    @Suppress("LongMethod")
     @Test
-    fun `given tags fetched, when start again refreshing, then move back to initial state`() = testCollectingUiStates {
+    fun `given new tags fetched, when onTagsChanged again, then state updates`() = testCollectingUiStates {
         // Given
-        val tag1 = ReaderTestUtils.createTag("tag1")
-        val tag2 = ReaderTestUtils.createTag("tag2")
+        val tag1 = ReaderTestUtils.createTag("tag1") // will be present both times
+        val tag2 = ReaderTestUtils.createTag("tag2") // will be present only first time
+        val tag3 = ReaderTestUtils.createTag("tag3") // will be present only second time
+
         val posts1 = ReaderPostList().apply {
             add(ReaderPost())
         }
         val posts2 = ReaderPostList().apply {
             add(ReaderPost())
         }
-        whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true)
+        val posts3 = ReaderPostList().apply {
+            add(ReaderPost())
+        }
+
         whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer {
             delay(100)
             posts1
@@ -405,9 +406,15 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
             delay(200)
             posts2
         }
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag3)).doSuspendableAnswer {
+            delay(300)
+            posts3
+        }
+
         mockMapInitialTagFeedItems()
         mockMapLoadingTagFeedItems()
         mockMapLoadedTagFeedItems()
+        mockMapInitialTagFeedItem()

         // When
         viewModel.onTagsChanged(listOf(tag1, tag2))
@@ -417,25 +424,31 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         viewModel.onItemEnteredView(getInitialTagFeedItem(tag2))
         advanceUntilIdle()

-        viewModel.onRefresh()
+        assertThat(collectedUiStates.last()).isEqualTo(
+            ReaderTagsFeedViewModel.UiState.Loaded(
+                data = listOf(
+                    getLoadedTagFeedItem(tag1),
+                    getLoadedTagFeedItem(tag2),
+                )
+            )
+        )

         // Then
-        viewModel.onTagsChanged(listOf(tag1, tag2))
+        viewModel.onTagsChanged(listOf(tag1, tag3))
         advanceUntilIdle()

-        val loadedState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.Loaded
-        assertThat(loadedState.data).isEqualTo(
-            listOf(
-                getInitialTagFeedItem(tag1),
-                getInitialTagFeedItem(tag2)
+        assertThat(collectedUiStates.last()).isEqualTo(
+            ReaderTagsFeedViewModel.UiState.Loaded(
+                data = listOf(
+                    getLoadedTagFeedItem(tag1), // still loaded even without entering view
+                    getInitialTagFeedItem(tag3),
+                )
             )
         )
-        assertThat(loadedState.isRefreshing).isFalse()
     }

-    @Suppress("LongMethod")
     @Test
-    fun `given no tags requested, when start, then UI state should update properly`() = testCollectingUiStates {
+    fun `given no tags, when onTagsChanged, then UI state should update properly`() = testCollectingUiStates {
         // Given
         val tags = emptyList<ReaderTag>()

@@ -447,7 +460,55 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         assertThat(collectedUiStates).last().isInstanceOf(ReaderTagsFeedViewModel.UiState.Empty::class.java)
     }

-    @Suppress("LongMethod")
+    @Test
+    fun `given tags fetched, when onTagsChanged 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(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true)
+            whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer {
+                delay(100)
+                posts1
+            }
+            whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer {
+                delay(200)
+                posts2
+            }
+            mockMapInitialTagFeedItems()
+            mockMapLoadingTagFeedItems()
+            mockMapLoadedTagFeedItems()
+
+            // When
+            viewModel.onTagsChanged(listOf(tag1, tag2))
+            advanceUntilIdle()
+            viewModel.onItemEnteredView(getInitialTagFeedItem(tag1))
+            advanceUntilIdle()
+            viewModel.onItemEnteredView(getInitialTagFeedItem(tag2))
+            advanceUntilIdle()
+
+            viewModel.onRefresh()
+
+            // Then
+            viewModel.onTagsChanged(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()
+        }
+
     @Test
     fun `given tags fetched, when refreshing, then update isRefreshing status`() = testCollectingUiStates {
         // Given
@@ -487,7 +548,6 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         assertThat(loadedState.isRefreshing).isTrue()
     }

-    @Suppress("LongMethod")
     @Test
     fun `given tags fetched, when refreshing, then RefreshTagsFeed action is posted`() = testCollectingUiStates {
         // Given
@@ -527,7 +587,6 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         assertThat(action).isEqualTo(ActionEvent.RefreshTags)
     }

-    @Suppress("LongMethod")
     @Test
     fun `given tags fetched and no connection, when refreshing, then show error message`() = testCollectingUiStates {
         // Given
@@ -877,6 +936,13 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
             }
     }

+    private fun mockMapInitialTagFeedItem() {
+        whenever(readerTagsFeedUiStateMapper.mapInitialTagFeedItem(any(), any(), any(), any()))
+            .thenAnswer {
+                getInitialTagFeedItem(it.getArgument(0))
+            }
+    }
+
     private fun getInitialTagFeedItem(tag: ReaderTag) = ReaderTagsFeedViewModel.TagFeedItem(
         ReaderTagsFeedViewModel.TagChip(tag, {}, {}),
         ReaderTagsFeedViewModel.PostList.Initial
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 e0bfbde05d8c..54df791458d7 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
@@ -256,7 +256,75 @@ class ReaderTagsFeedUiStateMapperTest : BaseUnitTest() {
     }

     @Test
-    fun `Should map loading posts UI state correctly`() {
+    fun `Should map loading TagFeedItem correctly`() {
+        // Given
+        val readerTag = ReaderTag(
+            "tag",
+            "tag",
+            "tag",
+            "endpoint",
+            ReaderTagType.FOLLOWED,
+        )
+        val onTagChipClick: (ReaderTag) -> Unit = {}
+        val onMoreFromTagClick: (ReaderTag) -> Unit = {}
+        val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {}
+        // When
+        val actual = classToTest.mapLoadingTagFeedItem(
+            tag = readerTag,
+            onTagChipClick = onTagChipClick,
+            onMoreFromTagClick = onMoreFromTagClick,
+            onItemEnteredView = onItemEnteredView,
+        )
+
+        // Then
+        val expected = ReaderTagsFeedViewModel.TagFeedItem(
+            tagChip = ReaderTagsFeedViewModel.TagChip(
+                tag = readerTag,
+                onTagChipClick = onTagChipClick,
+                onMoreFromTagClick = onMoreFromTagClick,
+            ),
+            postList = ReaderTagsFeedViewModel.PostList.Loading,
+            onItemEnteredView = onItemEnteredView,
+        )
+        assertEquals(expected, actual)
+    }
+
+    @Test
+    fun `Should map initial TagFeedItem correctly`() {
+        // Given
+        val readerTag = ReaderTag(
+            "tag",
+            "tag",
+            "tag",
+            "endpoint",
+            ReaderTagType.FOLLOWED,
+        )
+        val onTagChipClick: (ReaderTag) -> Unit = {}
+        val onMoreFromTagClick: (ReaderTag) -> Unit = {}
+        val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {}
+        // When
+        val actual = classToTest.mapInitialTagFeedItem(
+            tag = readerTag,
+            onTagChipClick = onTagChipClick,
+            onMoreFromTagClick = onMoreFromTagClick,
+            onItemEnteredView = onItemEnteredView,
+        )
+
+        // Then
+        val expected = ReaderTagsFeedViewModel.TagFeedItem(
+            tagChip = ReaderTagsFeedViewModel.TagChip(
+                tag = readerTag,
+                onTagChipClick = onTagChipClick,
+                onMoreFromTagClick = onMoreFromTagClick,
+            ),
+            postList = ReaderTagsFeedViewModel.PostList.Initial,
+            onItemEnteredView = onItemEnteredView,
+        )
+        assertEquals(expected, actual)
+    }
+
+    @Test
+    fun `Should map initial posts UI state correctly`() {
         // Given
         val onTagChipClick: (ReaderTag) -> Unit = {}
         val onMoreFromTagClick: (ReaderTag) -> Unit = {}


[Tags Feed] Fix duplicate tracking in Reader subfilter

Fixes #20814

- Keep a consistent reference to the SubFilterViewModel observers to leverage the existing Observable APIs, which handle duplicate observation, removal, and re-observation in case of multiple observe call and lifecycle state changes
- Fix sending the `reader_filter_sheet_item_selected` when selecting a filterable feed (e.g.: Subscriptions and Your Tags)

-----

## To Test:

1. Install Jetpack
2. Make sure `App Setting` -> `Privacy` -> `Collect Information` is enabled
3. Make sure `reader_tags_feed` Feature config is enabled (`Debug Settings` -> `Remote Features`
4. Go to Reader
5. Go to Subscriptions
6. **Verify** `reader_filter_sheet_item_selected` is not tracked
7. Tap the subfilter pill
8. **Verify** appropriate events are tracked ONLY ONCE
9. Tap a Site
10. **Verify** `reader_filter_sheet_item_selected` is tracked ONLY ONCE
11. Tap the pill again
12. Repeat 9-11 multiple times making sure events are tracked ONLY ONCE per trigger
13. Go to Your Tags
14. Repeat 6-12

-----

## Regression Notes

1. Potential unintended areas of impact

    - N/A

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

    - N/A

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

    - Unit tests not added for the one-time initialization mechanism since it is done completely in Fragment and ViewModel non-exposed APIs (`onCleared`)
    - Unit tests updated for bug fix related to sending `reader_filter_sheet_item_selected` when opening a filterable feed

-----

## 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] Fix `reader_filter_sheet_item_selected` tracked multiple times
`reader_filter_sheet_item_selected` should be tracked once when the selected filter changes, but it's being tracked multiple times.

diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt
index 474ef518d722..ec1427374780 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt
@@ -13,6 +13,7 @@ import androidx.compose.runtime.livedata.observeAsState
 import androidx.compose.ui.platform.ViewCompositionStrategy
 import androidx.core.view.isVisible
 import androidx.fragment.app.Fragment
+import androidx.lifecycle.Observer
 import androidx.lifecycle.ViewModelProvider
 import androidx.lifecycle.ViewModelStoreOwner
 import androidx.recyclerview.widget.RecyclerView
@@ -96,6 +97,86 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView

     private var readerSubsActivityResultLauncher: ActivityResultLauncher<Intent>? = null

+    private val wpMainActivityViewModel by lazy {
+        ViewModelProvider(
+            requireActivity(),
+            viewModelFactory
+        )[WPMainActivityViewModel::class.java]
+    }
+
+    // region SubgroupFilterViewModel Observers
+    // we need a reference to the observers so they are properly handled by the lifecycle and ViewModel owners, avoiding
+    // duplication, and ensuring they are properly removed when the Fragment is destroyed
+    private val currentSubfilterObserver = Observer<SubfilterListItem> { subfilterListItem ->
+        viewModel.onSubFilterItemSelected(subfilterListItem)
+    }
+
+    private val updateTagsAndSitesObserver = Observer<Event<EnumSet<UpdateTask>>> { event ->
+        event.applyIfNotHandled {
+            if (NetworkUtils.isNetworkAvailable(activity)) {
+                ReaderUpdateServiceStarter.startService(activity, this)
+            }
+        }
+    }
+
+    private val subFiltersObserver = Observer<List<SubfilterListItem>> { subFilters ->
+        val selectedTag = (viewModel.uiState.value as? ContentUiState)?.selectedReaderTag ?: return@Observer
+        viewModel.showTopBarFilterGroup(
+            selectedTag,
+            subFilters
+        )
+    }
+
+    private val bottomSheetUiStateObserver = Observer<Event<BottomSheetUiState>> { event ->
+        event.applyIfNotHandled {
+            val selectedTag = (viewModel.uiState.value as? ContentUiState)?.selectedReaderTag
+                ?: return@applyIfNotHandled
+            val viewModelKey = SubFilterViewModel.getViewModelKeyForTag(selectedTag)
+
+            val fm = childFragmentManager
+            var bottomSheet = fm.findFragmentByTag(SUBFILTER_BOTTOM_SHEET_TAG) as SubfilterBottomSheetFragment?
+            if (isVisible && bottomSheet == null) {
+                val (title, category) = this as BottomSheetVisible
+                bottomSheet = newInstance(
+                    viewModelKey,
+                    category,
+                    uiHelpers.getTextOfUiString(requireContext(), title)
+                )
+                bottomSheet.show(childFragmentManager, SUBFILTER_BOTTOM_SHEET_TAG)
+            } else if (!isVisible && bottomSheet != null) {
+                bottomSheet.dismiss()
+            }
+        }
+    }
+
+    private val bottomSheetActionObserver = Observer<Event<ActionType>> { event ->
+        event.applyIfNotHandled {
+            when (this) {
+                is OpenSubsAtPage -> {
+                    readerSubsActivityResultLauncher?.launch(
+                        ReaderActivityLauncher.createIntentShowReaderSubs(
+                            requireActivity(),
+                            tabIndex
+                        )
+                    )
+                }
+
+                is OpenLoginPage -> {
+                    wpMainActivityViewModel.onOpenLoginPage()
+                }
+
+                is OpenSearchPage -> {
+                    ReaderActivityLauncher.showReaderSearch(requireActivity())
+                }
+
+                is OpenSuggestedTagsPage -> {
+                    ReaderActivityLauncher.showReaderInterests(requireActivity())
+                }
+            }
+        }
+    }
+    // endregion
+
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         binding = ReaderFragmentLayoutBinding.bind(view).apply {
             initTopAppBar()
@@ -453,104 +534,41 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView
     }

     private fun SubFilterViewModel.initSubFilterViewModel(startedTag: ReaderTag, savedInstanceState: Bundle?) {
-        setupSubFilterBottomSheetObservers(startedTag)
+        bottomSheetUiState.observe(
+            viewLifecycleOwner,
+            bottomSheetUiStateObserver
+        )
+
+        bottomSheetAction.observe(
+            viewLifecycleOwner,
+            bottomSheetActionObserver
+        )

         currentSubFilter.observe(
-            viewLifecycleOwner
-        ) { subfilterListItem: SubfilterListItem ->
-            onSubfilterSelected(subfilterListItem)
-            viewModel.onSubFilterItemSelected(subfilterListItem)
-        }
+            viewLifecycleOwner,
+            currentSubfilterObserver
+        )

         updateTagsAndSites.observe(
-            viewLifecycleOwner
-        ) { event: Event<EnumSet<UpdateTask>> ->
-            event.applyIfNotHandled {
-                if (NetworkUtils.isNetworkAvailable(activity)) {
-                    ReaderUpdateServiceStarter.startService(activity, this)
-                }
-            }
-        }
+            viewLifecycleOwner,
+            updateTagsAndSitesObserver
+        )

         if (startedTag.isFilterable) {
             subFilters.observe(
-                viewLifecycleOwner
-            ) { subFilters: List<SubfilterListItem> ->
-                viewModel.showTopBarFilterGroup(
-                    startedTag,
-                    subFilters
-                )
-            }
+                viewLifecycleOwner,
+                subFiltersObserver
+            )

             updateTagsAndSites()
         } else {
             viewModel.hideTopBarFilterGroup(startedTag)
         }

-        // thomashortadev: not sure if always passing the same tags can cause problems
         start(startedTag, startedTag, savedInstanceState)
     }

-    private fun SubFilterViewModel.setupSubFilterBottomSheetObservers(startedTag: ReaderTag) {
-        val wpMainActivityViewModel = ViewModelProvider(
-            requireActivity(),
-            viewModelFactory
-        )[WPMainActivityViewModel::class.java]
-
-        val viewModelKey = SubFilterViewModel.getViewModelKeyForTag(startedTag)
-
-        bottomSheetUiState.observe(
-            viewLifecycleOwner
-        ) { event: Event<BottomSheetUiState> ->
-            event.applyIfNotHandled {
-                val fm = childFragmentManager
-                var bottomSheet = fm.findFragmentByTag(SUBFILTER_BOTTOM_SHEET_TAG) as SubfilterBottomSheetFragment?
-                if (isVisible && bottomSheet == null) {
-                    loadSubFilters()
-                    val (title, category) = this as BottomSheetVisible
-                    bottomSheet = newInstance(
-                        viewModelKey,
-                        category,
-                        uiHelpers.getTextOfUiString(requireContext(), title)
-                    )
-                    bottomSheet.show(childFragmentManager, SUBFILTER_BOTTOM_SHEET_TAG)
-                } else if (!isVisible && bottomSheet != null) {
-                    bottomSheet.dismiss()
-                }
-            }
-        }
-
-        bottomSheetAction.observe(
-            viewLifecycleOwner
-        ) { event: Event<ActionType> ->
-            event.applyIfNotHandled {
-                when (this) {
-                    is OpenSubsAtPage -> {
-                        readerSubsActivityResultLauncher?.launch(
-                            ReaderActivityLauncher.createIntentShowReaderSubs(
-                                requireActivity(),
-                                tabIndex
-                            )
-                        )
-                    }
-
-                    is OpenLoginPage -> {
-                        wpMainActivityViewModel.onOpenLoginPage()
-                    }
-
-                    is OpenSearchPage -> {
-                        ReaderActivityLauncher.showReaderSearch(requireActivity())
-                    }
-
-                    is OpenSuggestedTagsPage -> {
-                        ReaderActivityLauncher.showReaderInterests(requireActivity())
-                    }
-                }
-            }
-        }
-    }
-
     companion object {
         private const val SUBFILTER_BOTTOM_SHEET_TAG = "SUBFILTER_BOTTOM_SHEET_TAG"
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt
index eefb58a67f82..8f41c16c70e7 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt
@@ -9,8 +9,8 @@ import kotlinx.coroutines.withContext
 import org.greenrobot.eventbus.Subscribe
 import org.greenrobot.eventbus.ThreadMode
 import org.wordpress.android.analytics.AnalyticsTracker.Stat
-import org.wordpress.android.datasets.ReaderBlogTable
-import org.wordpress.android.datasets.ReaderTagTable
+import org.wordpress.android.datasets.ReaderBlogTableWrapper
+import org.wordpress.android.datasets.wrappers.ReaderTagTableWrapper
 import org.wordpress.android.fluxc.store.AccountStore
 import org.wordpress.android.models.ReaderBlog
 import org.wordpress.android.models.ReaderTag
@@ -38,7 +38,6 @@ import org.wordpress.android.util.UrlUtils
 import org.wordpress.android.viewmodel.Event
 import org.wordpress.android.viewmodel.ScopedViewModel
 import org.wordpress.android.viewmodel.SingleLiveEvent
-import java.util.Comparator
 import java.util.EnumSet
 import javax.inject.Inject
 import javax.inject.Named
@@ -50,7 +49,9 @@ class SubFilterViewModel @Inject constructor(
     private val subfilterListItemMapper: SubfilterListItemMapper,
     private val eventBusWrapper: EventBusWrapper,
     private val accountStore: AccountStore,
-    private val readerTracker: ReaderTracker
+    private val readerTracker: ReaderTracker,
+    private val readerTagTableWrapper: ReaderTagTableWrapper,
+    private val readerBlogTableWrapper: ReaderBlogTableWrapper,
 ) : ScopedViewModel(bgDispatcher) {
     private val _subFilters = MutableLiveData<List<SubfilterListItem>>()
     val subFilters: LiveData<List<SubfilterListItem>> = _subFilters
@@ -125,6 +126,7 @@ class SubFilterViewModel @Inject constructor(
             ""
         }
     }
+
     fun loadSubFilters() {
         launch {
             val filterList = ArrayList<SubfilterListItem>()
@@ -132,19 +134,19 @@ class SubFilterViewModel @Inject constructor(
             if (accountStore.hasAccessToken()) {
                 val organization = mTagFragmentStartedWith?.organization

-                val followedBlogs = ReaderBlogTable.getFollowedBlogs().let { blogList ->
+                val followedBlogs = readerBlogTableWrapper.getFollowedBlogs().let { blogList ->
                     // Filtering out all blogs not belonging to this VM organization if valid
                     blogList.filter { blog ->
                         organization?.let {
                             blog.organizationId == organization.orgId
                         } ?: false
                     }
-                }.sortedWith(Comparator { blog1, blog2 ->
+                }.sortedWith { blog1, blog2 ->
                     // sort followed blogs by name/domain to match display
                     val blogOneName = getBlogNameForComparison(blog1)
                     val blogTwoName = getBlogNameForComparison(blog2)
                     blogOneName.compareTo(blogTwoName, true)
-                })
+                }

                 filterList.addAll(
                     followedBlogs.map { blog ->
@@ -157,7 +159,7 @@ class SubFilterViewModel @Inject constructor(
                 )
             }

-            val tags = ReaderTagTable.getFollowedTags()
+            val tags = readerTagTableWrapper.getFollowedTags()

             for (tag in tags) {
                 filterList.add(
@@ -239,13 +241,14 @@ class SubFilterViewModel @Inject constructor(
         category: SubfilterCategory,
     ) {
         updateTagsAndSites()
+        loadSubFilters()
         _bottomSheetUiState.value = Event(
             BottomSheetVisible(
                 UiStringRes(category.titleRes),
                 category
             )
         )
-        val source = when(category) {
+        val source = when (category) {
             SubfilterCategory.SITES -> "blogs"
             SubfilterCategory.TAGS -> "tags"
         }
@@ -323,8 +326,8 @@ class SubFilterViewModel @Inject constructor(
     }

     fun onSubfilterSelected(subfilterListItem: SubfilterListItem) {
-        // We should not track subfilter selected if we're clearing a filter that is currently applied.
-        if (!subfilterListItem.isClearingFilter) {
+        // We should only track the selection of a subfilter if it's a tracked item (meaning it's a valid tag or site)
+        if (subfilterListItem.isTrackedItem) {
             val filterItemType = FilterItemType.fromSubfilterListItem(subfilterListItem)
             if (filterItemType != null) {
                 readerTracker.track(
@@ -332,7 +335,7 @@ class SubFilterViewModel @Inject constructor(
                     mutableMapOf(FilterItemType.trackingEntry(filterItemType))
                 )
             } else {
-                readerTracker.track(Stat.READER_FILTER_SHEET_ITEM_SELECTED,)
+                readerTracker.track(Stat.READER_FILTER_SHEET_ITEM_SELECTED)
             }
         }
         changeSubfilter(subfilterListItem, true, mTagFragmentStartedWith)
@@ -354,6 +357,7 @@ class SubFilterViewModel @Inject constructor(
             readerTracker.stop(ReaderTrackerType.SUBFILTERED_LIST)
         }
         _currentSubFilter.value = filter
+        onSubfilterSelected(filter)
     }

     fun onUserComesToReader() {
@@ -414,10 +418,10 @@ class SubFilterViewModel @Inject constructor(
         loadSubFilters()
     }

-    @Suppress("unused", "UNUSED_PARAMETER")
+    @Suppress("unused")
     @Subscribe(threadMode = ThreadMode.MAIN)
     fun onEventMainThread(event: ReaderEvents.FollowedBlogsFetched) {
-        if(event.didChange()) {
+        if (event.didChange()) {
             AppLog.d(T.READER, "Subfilter bottom sheet > followed blogs changed")
             loadSubFilters()
         }
@@ -449,7 +453,7 @@ class SubFilterViewModel @Inject constructor(

         companion object {
             fun fromSubfilterListItem(subfilterListItem: SubfilterListItem): FilterItemType? =
-                when(subfilterListItem.type) {
+                when (subfilterListItem.type) {
                     SubfilterListItem.ItemType.SITE -> Blog
                     SubfilterListItem.ItemType.TAG -> Tag
                     else -> null

diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelTest.kt
index bad452b02fd0..23e3a40f8b30 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelTest.kt
@@ -17,17 +17,20 @@ import org.mockito.kotlin.verify
 import org.mockito.kotlin.whenever
 import org.wordpress.android.BaseUnitTest
 import org.wordpress.android.analytics.AnalyticsTracker
+import org.wordpress.android.datasets.ReaderBlogTableWrapper
+import org.wordpress.android.datasets.wrappers.ReaderTagTableWrapper
 import org.wordpress.android.fluxc.model.AccountModel
 import org.wordpress.android.fluxc.store.AccountStore
 import org.wordpress.android.getOrAwaitValue
 import org.wordpress.android.models.ReaderBlog
 import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.models.ReaderTagList
 import org.wordpress.android.models.ReaderTagType.BOOKMARKED
 import org.wordpress.android.models.ReaderTagType.TAGS
+import org.wordpress.android.ui.Organization
 import org.wordpress.android.ui.prefs.AppPrefsWrapper
 import org.wordpress.android.ui.reader.ReaderSubsActivity
 import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType
-import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic
 import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask
 import org.wordpress.android.ui.reader.subfilter.ActionType.OpenLoginPage
 import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSubsAtPage
@@ -78,7 +81,10 @@ class SubFilterViewModelTest : BaseUnitTest() {
     private lateinit var savedState: Bundle

     @Mock
-    private lateinit var filter: SubfilterListItem
+    private lateinit var readerTagTableWrapper: ReaderTagTableWrapper
+
+    @Mock
+    private lateinit var readerBlogTableWrapper: ReaderBlogTableWrapper

     private lateinit var viewModel: SubFilterViewModel

@@ -93,7 +99,9 @@ class SubFilterViewModelTest : BaseUnitTest() {
             subfilterListItemMapper,
             eventBusWrapper,
             accountStore,
-            readerTracker
+            readerTracker,
+            readerTagTableWrapper,
+            readerBlogTableWrapper,
         )

         viewModel.start(initialTag, savedTag, savedState)
@@ -102,6 +110,10 @@ class SubFilterViewModelTest : BaseUnitTest() {
     @Test
     fun `current subfilter is set back when we have a previous intance state`() {
         val json = "{\"blogId\":0,\"feedId\":0,\"tagSlug\":\"news\",\"tagType\":1,\"type\":4}"
+        val filter = Site(
+            blog = ReaderBlog(),
+            onClickAction = mock(),
+        )

         whenever(savedState.getString(SubFilterViewModel.ARG_CURRENT_SUBFILTER_JSON)).thenReturn(json)
         whenever(subfilterListItemMapper.fromJson(eq(json), any(), any())).thenReturn(filter)
@@ -113,7 +125,9 @@ class SubFilterViewModelTest : BaseUnitTest() {
             subfilterListItemMapper,
             eventBusWrapper,
             accountStore,
-            readerTracker
+            readerTracker,
+            readerTagTableWrapper,
+            readerBlogTableWrapper,
         )

         viewModel.start(initialTag, savedTag, savedState)
@@ -288,11 +302,11 @@ class SubFilterViewModelTest : BaseUnitTest() {

     @Test
     fun `view model updates the tags and sites and asks to show the bottom sheet when filters button is tapped`() {
-        var updateTasks: EnumSet<ReaderUpdateLogic.UpdateTask>? = null
-        var uiState: BottomSheetUiState? = null
+        mockReaderTableEmpty()
+
+        var updateTasks: EnumSet<UpdateTask>? = null

         viewModel.updateTagsAndSites.observeForever { updateTasks = it.peekContent() }
-        viewModel.bottomSheetUiState.observeForever { uiState = it.peekContent() }

         viewModel.onSubFiltersListButtonClicked(SubfilterCategory.SITES)

@@ -302,10 +316,48 @@ class SubFilterViewModelTest : BaseUnitTest() {
                 UpdateTask.FOLLOWED_BLOGS
             )
         )
+    }
+
+    @Test
+    fun `view model asks to show the bottom sheet when filters button is tapped`() {
+        mockReaderTableEmpty()
+
+        var uiState: BottomSheetUiState? = null
+
+        viewModel.bottomSheetUiState.observeForever { uiState = it.peekContent() }
+
+        viewModel.onSubFiltersListButtonClicked(SubfilterCategory.SITES)

         assertThat(uiState).isInstanceOf(BottomSheetVisible::class.java)
     }

+    @Test
+    fun `view model updates subfilters when filters button is tapped`() {
+        whenever(initialTag.organization).thenReturn(Organization.NO_ORGANIZATION)
+        whenever(accountStore.hasAccessToken()).thenReturn(true)
+        whenever(readerTagTableWrapper.getFollowedTags()).thenReturn(
+            ReaderTagList().apply {
+                add(ReaderTag("a", "a", "a", "endpoint-a", TAGS))
+                add(ReaderTag("b", "b", "b", "endpoint-b", TAGS))
+                add(ReaderTag("c", "c", "c", "endpoint-c", TAGS))
+            }
+        )
+
+        whenever(readerBlogTableWrapper.getFollowedBlogs()).thenReturn(
+            List(2) {
+                ReaderBlog().apply { organizationId = Organization.NO_ORGANIZATION.orgId }
+            }
+        )
+
+        var subFilters: List<SubfilterListItem>? = null
+
+        viewModel.subFilters.observeForever { subFilters = it }
+
+        viewModel.onSubFiltersListButtonClicked(SubfilterCategory.SITES)
+
+        assertThat(subFilters).hasSize(5)
+    }
+
     @Test
     fun `view model hides the bottom sheet when it is cancelled`() {
         var uiState: BottomSheetUiState? = null
@@ -320,7 +372,11 @@ class SubFilterViewModelTest : BaseUnitTest() {
     @Test
     fun `bottom sheet is hidden when a filter is tapped on`() {
         var uiState: BottomSheetUiState? = null
-        val filter: SubfilterListItem = mock()
+        val filter: SubfilterListItem = Site(
+            isSelected = false,
+            onClickAction = mock(),
+            blog = ReaderBlog(),
+        )

         viewModel.setSubfilterFromTag(savedTag)

@@ -430,9 +486,8 @@ class SubFilterViewModelTest : BaseUnitTest() {
     }

     @Test
-    fun `Should NOT track READER_FILTER_SHEET_ITEM_SELECTED if clearing filter when onSubfilterSelected is called`() {
+    fun `Should NOT track READER_FILTER_SHEET_ITEM_SELECTED if SiteAll when onSubfilterSelected is called`() {
         val filter = SiteAll(
-            isClearingFilter = true,
             onClickAction = {},
         )
         viewModel.onSubfilterSelected(filter)
@@ -440,13 +495,17 @@ class SubFilterViewModelTest : BaseUnitTest() {
     }

     @Test
-    fun `Should track READER_FILTER_SHEET_ITEM_SELECTED if NOT clearing filter when onSubfilterSelected is called`() {
-        val filter = SiteAll(
-            isClearingFilter = false,
-            onClickAction = {},
-        )
+    fun `Should NOT track READER_FILTER_SHEET_ITEM_SELECTED if Divider when onSubfilterSelected is called`() {
+        val filter = SubfilterListItem.Divider
+        viewModel.onSubfilterSelected(filter)
+        verify(readerTracker, times(0)).track(AnalyticsTracker.Stat.READER_FILTER_SHEET_ITEM_SELECTED)
+    }
+
+    @Test
+    fun `Should NOT track READER_FILTER_SHEET_ITEM_SELECTED if SectionTitle when onSubfilterSelected is called`() {
+        val filter = SubfilterListItem.SectionTitle(UiStringText("test"))
         viewModel.onSubfilterSelected(filter)
-        verify(readerTracker).track(AnalyticsTracker.Stat.READER_FILTER_SHEET_ITEM_SELECTED)
+        verify(readerTracker, times(0)).track(AnalyticsTracker.Stat.READER_FILTER_SHEET_ITEM_SELECTED)
     }

     @Test
@@ -484,4 +543,11 @@ class SubFilterViewModelTest : BaseUnitTest() {
             assertThat(viewModel.isTitleContainerVisible.getOrAwaitValue()).isEqualTo(isTitleContainerVisible)
         }
     }
+
+    private fun mockReaderTableEmpty() {
+        whenever(initialTag.organization).thenReturn(Organization.NO_ORGANIZATION)
+        whenever(accountStore.hasAccessToken()).thenReturn(true)
+        whenever(readerTagTableWrapper.getFollowedTags()).thenReturn(ReaderTagList())
+        whenever(readerBlogTableWrapper.getFollowedBlogs()).thenReturn(emptyList())
+    }
 }