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

Issue:
🤖 Bring the user straight to the comments reader view
Non-admin users that cannot moderate comment should skip the comment details screen and be directed straight to the comments reader view


Discussion hints:

<!--
  2 failure:  Please add a type..., Please add a feat...
  0 warning: 
  
  
  DangerID: danger-id-Peril;
-->

<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Fails</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:no_entry_sign:</td>
      <td>

  Please add a type label to this issue. e.g. '[Type] Enhancement'
  </td>
    </tr>
  
<tr>
      <td>:no_entry_sign:</td>
      <td>Please add a feature label to this issue. e.g. 'Stats'</td>
    </tr>
  </tbody>
</table>




<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a>
</p>

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


Pull request: 🤖 Bring the user straight to the comments reader view
Fixes #20096

-----

## Description
This PR navigates non-admin users that cannot moderate a comment directly to the comments reader view

## To Test:
### A. Non-admin user
1. Create a post with an authorised user and add some comments
2. Log in with another user in the Jetpack app that does not administer the blog from (1)
3. Search and follow the blog from (1)
4. Find the post from (1) and [follow the conversation in the reader enabling notifications](https://github.com/wordpress-mobile/WordPress-Android/assets/304044/49860ff1-4afe-4fc8-b8bd-ea3f8e965648)
5. Write another comment as the authorised user on the post from (1) (e.g. on the web)
6. **Verify** that you receive a notification for that comment 
7. Tap on the comment notification from 3 **in the notifications list**
8. **Expect** the Reader Comments screen to appear scrolled (if needed) to the comment from 3.

### B. Admin user ( Regression )
1. Log in with an admin user
2. Open the notifications in the app
3. Navigate to Notifications.
4. Tap on the comment notification from A5 **in the notifications list**
5. **Expect** the Comment Detail screen to appear were the user can moderate the comment

### C. Push notifications
Repeat (A) and (B) above by tapping on the **system push notification**

-----

## Regression Notes

1. Potential unintended areas of impact

    - Notifications detail view

2. What I did to test those areas of impact (or what existing automated tests I relied on)

    - Manual testing

3. What automated tests I added (or what prevented me from doing so)

    - Added `NotificationsListViewModelTest` with the logic for opening the comment in the reader

-----

## 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:

- [ ] WordPress.com sites and self-hosted Jetpack sites.
- [ ] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [ ] Talkback.
- [ ] Languages with large words or with letters/accents not frequently used in English.
- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [ ] Large and small screen sizes. (Tablet and smaller phones)
- [ ] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)


Test command: ./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.notifications.NotificationsListViewModelTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
index 6d67608adebb..3d432ded823f 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
@@ -108,6 +108,7 @@
 import org.wordpress.android.ui.mysite.cards.quickstart.QuickStartRepository;
 import org.wordpress.android.ui.notifications.NotificationEvents;
 import org.wordpress.android.ui.notifications.NotificationsListFragment;
+import org.wordpress.android.ui.notifications.NotificationsListViewModel;
 import org.wordpress.android.ui.notifications.SystemNotificationsTracker;
 import org.wordpress.android.ui.notifications.adapters.NotesAdapter;
 import org.wordpress.android.ui.notifications.receivers.NotificationsPendingDraftsReceiver;
@@ -127,7 +128,9 @@
 import org.wordpress.android.ui.prefs.privacy.banner.PrivacyBannerFragment;
 import org.wordpress.android.ui.quickstart.QuickStartMySitePrompts;
 import org.wordpress.android.ui.quickstart.QuickStartTracker;
+import org.wordpress.android.ui.reader.ReaderActivityLauncher;
 import org.wordpress.android.ui.reader.ReaderFragment;
+import org.wordpress.android.ui.reader.comments.ThreadedCommentsActionSource;
 import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask;
 import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarter;
 import org.wordpress.android.ui.reader.tracker.ReaderTracker;
@@ -190,6 +193,9 @@
 import static org.wordpress.android.util.extensions.InAppReviewExtensionsKt.logException;
 
 import dagger.hilt.android.AndroidEntryPoint;
+import kotlin.Unit;
+import kotlin.jvm.functions.Function0;
+import kotlin.jvm.functions.Function3;
 
 /**
  * Main activity which hosts sites, reader, me and notifications pages
@@ -251,6 +257,7 @@ public class WPMainActivity extends LocaleAwareActivity implements
     private ModalLayoutPickerViewModel mMLPViewModel;
     @NonNull private ReviewViewModel mReviewViewModel;
     private BloggingRemindersViewModel mBloggingRemindersViewModel;
+    private NotificationsListViewModel mNotificationsViewModel;
     private FloatingActionButton mFloatingActionButton;
     private static final String MAIN_BOTTOM_SHEET_TAG = "MAIN_BOTTOM_SHEET_TAG";
     private static final String BLOGGING_REMINDERS_BOTTOM_SHEET_TAG = "BLOGGING_REMINDERS_BOTTOM_SHEET_TAG";
@@ -1057,11 +1064,34 @@ public void onTokenInvalid() {
                     // we processed the voice reply, so we exit this function immediately
                     return;
                 } else {
-                    boolean shouldShowKeyboard =
-                            getIntent().getBooleanExtra(NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA, false);
-                    NotificationsListFragment
-                            .openNoteForReply(this, noteId, shouldShowKeyboard, null,
-                                    NotesAdapter.FILTERS.FILTER_ALL, true);
+                    if (mNotificationsViewModel == null) {
+                        mNotificationsViewModel = new ViewModelProvider(this).get(NotificationsListViewModel.class);
+                    }
+                    mNotificationsViewModel.openNote(noteId, new Function3<Long, Long, Long, Unit>() {
+                                @Nullable @Override
+                                public Unit invoke(@NonNull Long siteId, @NonNull Long postId,
+                                                   @NonNull Long commentId) {
+                                    ReaderActivityLauncher.showReaderComments(
+                                            WPMainActivity.this,
+                                            siteId,
+                                            postId,
+                                            commentId,
+                                            ThreadedCommentsActionSource.COMMENT_NOTIFICATION.getSourceDescription()
+                                    );
+                                    return null;
+                                }
+                            }, new Function0<Unit>() {
+                                @Nullable @Override
+                                public Unit invoke() {
+                                    boolean shouldShowKeyboard = getIntent().getBooleanExtra(
+                                            NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA,
+                                            false);
+                                    NotificationsListFragment.openNoteForReply(WPMainActivity.this, noteId,
+                                            shouldShowKeyboard, null, NotesAdapter.FILTERS.FILTER_ALL, true);
+                                    return null;
+                                }
+                            }
+                    );
                 }
             } else {
                 AppLog.e(T.NOTIFS, "app launched from a PN that doesn't have a note_id in it!!");
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt
index 36d1a996cbde..f578ed92910f 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt
@@ -57,6 +57,8 @@ import org.wordpress.android.ui.notifications.adapters.NotesAdapter.DataLoadedLi
 import org.wordpress.android.ui.notifications.adapters.NotesAdapter.FILTERS
 import org.wordpress.android.ui.notifications.services.NotificationsUpdateServiceStarter
 import org.wordpress.android.ui.notifications.utils.NotificationsActions
+import org.wordpress.android.ui.reader.ReaderActivityLauncher
+import org.wordpress.android.ui.reader.comments.ThreadedCommentsActionSource
 import org.wordpress.android.util.AniUtils
 import org.wordpress.android.util.AppLog
 import org.wordpress.android.util.AppLog.T
@@ -232,9 +234,24 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
             }
             incrementInteractions(APP_REVIEWS_EVENT_INCREMENTED_BY_CHECKING_NOTIFICATION)
 
-            // Open the latest version of this note in case it has changed, which can happen if the note was tapped
-            // from the list after it was updated by another fragment (such as NotificationsDetailListFragment).
-            openNoteForReply(activity, noteId, false, null, notesAdapter.currentFilter, false)
+            viewModel.openNote(
+                noteId,
+                { siteId, postId, commentId ->
+                    ReaderActivityLauncher.showReaderComments(
+                        activity,
+                        siteId,
+                        postId,
+                        commentId,
+                        ThreadedCommentsActionSource.COMMENT_NOTIFICATION.sourceDescription
+                    )
+                },
+                {
+                    // Open the latest version of this note in case it has changed, which can happen if the note was
+                    // tapped from the list after it was updated by another fragment (such as the
+                    // NotificationsDetailListFragment).
+                    openNoteForReply(activity, noteId, filter = notesAdapter.currentFilter)
+                }
+            )
         }
     }
     private val mOnScrollListener: OnScrollListener = object : OnScrollListener() {
@@ -516,10 +533,10 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l
         fun openNoteForReply(
             activity: Activity?,
             noteId: String?,
-            shouldShowKeyboard: Boolean,
-            replyText: String?,
-            filter: FILTERS?,
-            isTappedFromPushNotification: Boolean
+            shouldShowKeyboard: Boolean = false,
+            replyText: String? = null,
+            filter: FILTERS? = null,
+            isTappedFromPushNotification: Boolean = false,
         ) {
             if (noteId == null || activity == null || activity.isFinishing) {
                 return
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt
index cf2300568044..fae774fb59c4 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListViewModel.kt
@@ -13,6 +13,7 @@ import org.wordpress.android.fluxc.model.SiteModel
 import org.wordpress.android.fluxc.store.AccountStore
 import org.wordpress.android.fluxc.store.CommentsStore
 import org.wordpress.android.fluxc.store.SiteStore
+import org.wordpress.android.fluxc.utils.AppLogWrapper
 import org.wordpress.android.models.Note
 import org.wordpress.android.models.Notification.PostNotification
 import org.wordpress.android.modules.BG_THREAD
@@ -21,8 +22,11 @@ import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalOverlayUtil
 import org.wordpress.android.ui.jetpackoverlay.JetpackOverlayConnectedFeature.NOTIFICATIONS
 import org.wordpress.android.ui.notifications.NotificationEvents.NotificationsChanged
 import org.wordpress.android.ui.notifications.utils.NotificationsActions
+import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper
 import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.ui.reader.actions.ReaderActions
 import org.wordpress.android.ui.reader.actions.ReaderPostActionsWrapper
+import org.wordpress.android.util.AppLog
 import org.wordpress.android.viewmodel.Event
 import org.wordpress.android.viewmodel.ScopedViewModel
 import javax.inject.Inject
@@ -34,6 +38,8 @@ class NotificationsListViewModel @Inject constructor(
     private val appPrefsWrapper: AppPrefsWrapper,
     private val jetpackFeatureRemovalOverlayUtil: JetpackFeatureRemovalOverlayUtil,
     private val gcmMessageHandler: GCMMessageHandler,
+    private val notificationsUtilsWrapper: NotificationsUtilsWrapper,
+    private val appLogWrapper: AppLogWrapper,
     private val siteStore: SiteStore,
     private val commentStore: CommentsStore,
     private val readerPostTableWrapper: ReaderPostTableWrapper,
@@ -97,6 +103,36 @@ class NotificationsListViewModel @Inject constructor(
         }
     }
 
+    fun openNote(
+        noteId: String?,
+        openInTheReader: (siteId: Long, postId: Long, commentId: Long) -> Unit,
+        openDetailView: () -> Unit
+    ) {
+        val note = noteId?.let { notificationsUtilsWrapper.getNoteById(noteId) }
+        if (note != null && note.isCommentType && !note.canModerate()) {
+            val readerPost = readerPostTableWrapper.getBlogPost(note.siteId.toLong(), note.postId.toLong(), false)
+            if (readerPost != null) {
+                openInTheReader(note.siteId.toLong(), note.postId.toLong(), note.commentId)
+            } else {
+                readerPostActionsWrapper.requestBlogPost(
+                    note.siteId.toLong(),
+                    note.postId.toLong(),
+                    object : ReaderActions.OnRequestListener<String> {
+                        override fun onSuccess(result: String?) {
+                            openInTheReader(note.siteId.toLong(), note.postId.toLong(), note.commentId)
+                        }
+
+                        override fun onFailure(statusCode: Int) {
+                            appLogWrapper.w(AppLog.T.NOTIFS, "Failed to fetch post for comment: $statusCode")
+                            openDetailView()
+                        }
+                    })
+            }
+        } else {
+            openDetailView()
+        }
+    }
+
     fun likePost(note: Note, liked: Boolean) = launch {
         note.setLikedPost(liked)
         _updatedNote.postValue(note)


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/notifications/NotificationsListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/notifications/NotificationsListViewModelTest.kt
new file mode 100644
index 000000000000..ff44cca7c117
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/notifications/NotificationsListViewModelTest.kt
@@ -0,0 +1,216 @@
+package org.wordpress.android.ui.notifications
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper
+import org.wordpress.android.fluxc.store.AccountStore
+import org.wordpress.android.fluxc.store.CommentsStore
+import org.wordpress.android.fluxc.store.SiteStore
+import org.wordpress.android.fluxc.utils.AppLogWrapper
+import org.wordpress.android.models.Note
+import org.wordpress.android.models.ReaderPost
+import org.wordpress.android.push.GCMMessageHandler
+import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalOverlayUtil
+import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper
+import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.ui.reader.actions.ReaderActions
+import org.wordpress.android.ui.reader.actions.ReaderPostActionsWrapper
+
+private const val REQUEST_BLOG_LISTENER_PARAM_POSITION = 2
+
+@ExperimentalCoroutinesApi
+@RunWith(MockitoJUnitRunner::class)
+class NotificationsListViewModelTest : BaseUnitTest() {
+    @Mock
+    private lateinit var appPrefsWrapper: AppPrefsWrapper
+
+    @Mock
+    private lateinit var jetpackFeatureRemovalOverlayUtil: JetpackFeatureRemovalOverlayUtil
+
+    @Mock
+    private lateinit var gcmMessageHandler: GCMMessageHandler
+
+    @Mock
+    private lateinit var notificationsUtilsWrapper: NotificationsUtilsWrapper
+
+    @Mock
+    private lateinit var readerPostTableWrapper: ReaderPostTableWrapper
+
+    @Mock
+    private lateinit var readerPostActionsWrapper: ReaderPostActionsWrapper
+
+    @Mock lateinit var appLogWrapper: AppLogWrapper
+
+    @Mock
+    private lateinit var siteStore: SiteStore
+
+    @Mock
+    private lateinit var commentStore: CommentsStore
+
+    @Mock
+    private lateinit var  accountStore: AccountStore
+
+    @Mock
+    private lateinit var action: ActionHandler
+
+    private lateinit var viewModel: NotificationsListViewModel
+
+    @Before
+    fun setup() {
+        viewModel = NotificationsListViewModel(
+            testDispatcher(),
+            appPrefsWrapper,
+            jetpackFeatureRemovalOverlayUtil,
+            gcmMessageHandler,
+            notificationsUtilsWrapper,
+            appLogWrapper,
+            siteStore,
+            commentStore,
+            readerPostTableWrapper,
+            readerPostActionsWrapper,
+            accountStore,
+        )
+    }
+
+    @Test
+    fun `WHEN the note cannot be retrieved THEN try opening the detail view`() {
+        // Given
+        val noteId = "1"
+        whenever(notificationsUtilsWrapper.getNoteById(noteId)).thenReturn(null)
+
+        // When
+        viewModel.openNote(noteId, action::openInTheReader, action::openDetailView)
+
+        // Then
+        verify(action, times(0)).openInTheReader(any(), any(), any())
+        verify(action, times(1)).openDetailView()
+    }
+
+    @Test
+    fun `WHEN the note is not a comment THEN open detail view`() {
+        // Given
+        val noteId = "1"
+        val note = mock<Note>()
+        whenever(notificationsUtilsWrapper.getNoteById(noteId)).thenReturn(note)
+        whenever(note.isCommentType).thenReturn(false)
+
+        // When
+        viewModel.openNote(noteId, action::openInTheReader, action::openDetailView)
+
+        // Then
+        verify(action, times(0)).openInTheReader(any(), any(), any())
+        verify(action, times(1)).openDetailView()
+    }
+
+    @Test
+    fun `WHEN the note is a comment that can be moderated THEN open detail view`() {
+        // Given
+        val noteId = "1"
+        val note = mock<Note>()
+        whenever(notificationsUtilsWrapper.getNoteById(noteId)).thenReturn(note)
+        whenever(note.isCommentType).thenReturn(true)
+        whenever(note.canModerate()).thenReturn(true)
+
+        // When
+        viewModel.openNote(noteId, action::openInTheReader, action::openDetailView)
+
+        // Then
+        verify(action, times(0)).openInTheReader(any(), any(), any())
+        verify(action, times(1)).openDetailView()
+    }
+
+    @Test
+    fun `WHEN the note is a comment that cannot be moderated and the reader post exists THEN open in reader`() {
+        // Given
+        val noteId = "1"
+        val siteId = 1L
+        val postId = 2L
+        val commentId = 3L
+        val note = mock<Note>()
+        val readerPost = mock<ReaderPost>()
+        whenever(notificationsUtilsWrapper.getNoteById(noteId)).thenReturn(note)
+        whenever(note.siteId).thenReturn(siteId.toInt())
+        whenever(note.postId).thenReturn(postId.toInt())
+        whenever(note.commentId).thenReturn(commentId)
+        whenever(note.isCommentType).thenReturn(true)
+        whenever(note.canModerate()).thenReturn(false)
+        whenever(readerPostTableWrapper.getBlogPost(siteId, postId, false)).thenReturn(readerPost)
+
+        // When
+        viewModel.openNote(noteId, action::openInTheReader, action::openDetailView)
+
+        // Then
+        verify(action, times(1)).openInTheReader(siteId, postId, commentId)
+        verify(action, times(0)).openDetailView()
+    }
+
+    @Test
+    fun `WHEN the note is a comment that cannot be moderated and the reader post is retrieved THEN open in reader`() {
+        // Given
+        val noteId = "1"
+        val siteId = 1L
+        val postId = 2L
+        val commentId = 3L
+        val note = mock<Note>()
+        whenever(notificationsUtilsWrapper.getNoteById(noteId)).thenReturn(note)
+        whenever(note.siteId).thenReturn(siteId.toInt())
+        whenever(note.postId).thenReturn(postId.toInt())
+        whenever(note.commentId).thenReturn(commentId)
+        whenever(note.isCommentType).thenReturn(true)
+        whenever(note.canModerate()).thenReturn(false)
+        whenever(readerPostTableWrapper.getBlogPost(siteId, postId, false)).thenReturn(null)
+        whenever(readerPostActionsWrapper.requestBlogPost(any(), any(), any())).then {
+            (it.arguments[REQUEST_BLOG_LISTENER_PARAM_POSITION] as ReaderActions.OnRequestListener<*>)
+                .onSuccess(null)
+        }
+
+        // When
+        viewModel.openNote(noteId, action::openInTheReader, action::openDetailView)
+
+        // Then
+        verify(action, times(1)).openInTheReader(siteId, postId, commentId)
+        verify(action, times(0)).openDetailView()
+    }
+
+    @Test
+    fun `WHEN the comment note cannot be moderated and the reader post retrieval fails THEN open detail view`() {
+        // Given
+        val noteId = "1"
+        val siteId = 1L
+        val postId = 2L
+        val note = mock<Note>()
+        whenever(notificationsUtilsWrapper.getNoteById(noteId)).thenReturn(note)
+        whenever(note.siteId).thenReturn(siteId.toInt())
+        whenever(note.postId).thenReturn(postId.toInt())
+        whenever(note.isCommentType).thenReturn(true)
+        whenever(note.canModerate()).thenReturn(false)
+        whenever(readerPostTableWrapper.getBlogPost(siteId, postId, false)).thenReturn(null)
+        whenever(readerPostActionsWrapper.requestBlogPost(any(), any(), any())).then {
+            (it.arguments[REQUEST_BLOG_LISTENER_PARAM_POSITION] as ReaderActions.OnRequestListener<*>)
+                .onFailure(500)
+        }
+
+        // When
+        viewModel.openNote(noteId, action::openInTheReader, action::openDetailView)
+
+        // Then
+        verify(action, times(0)).openInTheReader(any(), any(), any())
+        verify(action, times(1)).openDetailView()
+    }
+
+    interface ActionHandler {
+        fun openInTheReader(siteId: Long, postId: Long, commentId: Long)
+
+        fun openDetailView()
+    }
+}