diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/NotificationsTableWrapper.kt b/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/NotificationsTableWrapper.kt index 8c667439ad2c..542f01854697 100644 --- a/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/NotificationsTableWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/NotificationsTableWrapper.kt @@ -12,4 +12,6 @@ class NotificationsTableWrapper @Inject constructor() { fun saveNotes(notes: List, clearBeforeSaving: Boolean) { NotificationsTable.saveNotes(notes, clearBeforeSaving) } + + fun getNoteById(noteId: String): Note? = NotificationsTable.getNoteById(noteId) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java b/WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java index 26f8801c91cf..eec446804957 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java @@ -745,7 +745,7 @@ public static void viewPageParentForResult(@NonNull Fragment fragment, @NonNull public static void viewUnifiedComments(Context context, SiteModel site) { Intent intent; - if (shouldUseRsCommentsList(context, site)) { + if (shouldUseRsComments(context, site)) { intent = CommentsRsListActivity.Companion.createIntent(context); } else { intent = new Intent(context, UnifiedCommentsActivity.class); @@ -755,9 +755,13 @@ public static void viewUnifiedComments(Context context, SiteModel site) { AnalyticsUtils.trackWithSiteDetails(AnalyticsTracker.Stat.OPENED_COMMENTS, site); } - private static boolean shouldUseRsCommentsList(@NonNull Context context, @NonNull SiteModel site) { + /** + * Whether the wordpress-rs comments screens (list, and the notification-hosted detail) should + * be used for {@code site} instead of the legacy (FluxC) ones. + */ + public static boolean shouldUseRsComments(@NonNull Context context, @NonNull SiteModel site) { // The rs client can only authenticate WP.com-accessed sites or self-hosted sites with an - // application password; everywhere else keep the legacy (FluxC) comments list. + // application password; everywhere else keep the legacy (FluxC) comments screens. if (!site.isUsingWpComRestApi() && !site.hasApplicationPassword()) { return false; } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt index 15e62225be12..71710e84eb3c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt @@ -37,11 +37,13 @@ import org.wordpress.android.fluxc.model.CommentStatus.UNAPPROVED import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.ui.CollapseFullScreenDialogFragment import org.wordpress.android.ui.CommentFullScreenDialogFragment +import org.wordpress.android.ui.ScrollableViewInitializedListener import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.Close import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.LaunchEditComment import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.OpenPostInReader import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.ReplySent import org.wordpress.android.ui.comments.unified.UnifiedCommentDetailsViewModel.CommentDetailsUiState +import org.wordpress.android.ui.notifications.NotificationsListFragment import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.reader.ReaderActivityLauncher import org.wordpress.android.ui.suggestion.util.SuggestionServiceConnectionManager @@ -57,6 +59,7 @@ import org.wordpress.android.util.SnackbarItem.Info import org.wordpress.android.util.SnackbarSequencer import org.wordpress.android.util.ToastUtils import org.wordpress.android.util.WPLinkMovementMethod +import org.wordpress.android.util.extensions.focusAndShowKeyboard import org.wordpress.android.util.extensions.getColorFromAttribute import org.wordpress.android.util.extensions.getColorResIdFromAttribute import org.wordpress.android.util.extensions.getSerializableCompat @@ -89,8 +92,13 @@ class UnifiedCommentDetailsFragment : private lateinit var site: SiteModel private var remoteCommentId: Long = 0 + private var noteId: String? = null private var suggestionServiceConnectionManager: SuggestionServiceConnectionManager? = null + // The result reported to the launching screen, accumulated across observers: moderation adds + // the note extras the notifications list uses to update the moderated note's row. + private val resultIntent = Intent() + // The comment HTML currently rendered, so transient state changes (like/reply progress/status) // don't re-parse the body — which is expensive and resets the user's text selection. private var renderedCommentHtml: String? = null @@ -117,16 +125,20 @@ class UnifiedCommentDetailsFragment : site = requireNotNull(arguments?.getSerializableCompat(WordPress.SITE)) remoteCommentId = requireArguments().getLong(KEY_REMOTE_COMMENT_ID) + noteId = arguments?.getString(KEY_NOTE_ID) UnifiedCommentDetailsFragmentBinding.bind(view).apply { binding = this setupClickListeners() - layoutCommentBox.setupReplyBox() + layoutCommentBox.setupReplyBox(isFreshView = savedInstanceState == null) layoutCommentBox.setupSuggestions() setupObservers() + // Lets the notifications host lift its app bar with the comment's scroll position; + // the rs comments list host doesn't implement the listener, so this is a no-op there. + (activity as? ScrollableViewInitializedListener)?.onScrollableViewInitialized(scrollView.id) } - viewModel.start(site, remoteCommentId) + viewModel.start(site, remoteCommentId, noteId) } private fun UnifiedCommentDetailsFragmentBinding.setupClickListeners() { @@ -144,7 +156,7 @@ class UnifiedCommentDetailsFragment : if (SiteUtils.isAccessedViaWPComRest(site)) View.VISIBLE else View.GONE } - private fun ReaderIncludeCommentBoxBinding.setupReplyBox() { + private fun ReaderIncludeCommentBoxBinding.setupReplyBox(isFreshView: Boolean) { layoutContainer.visibility = View.VISIBLE editComment.initializeWithPrefix('@') editComment.doAfterTextChanged { btnSubmitReply.isEnabled = !it.isNullOrBlank() } @@ -154,6 +166,16 @@ class UnifiedCommentDetailsFragment : // application-password sites, which would collide drafts across sites. editComment.autoSaveTextHelper.uniqueId = "${site.id}-$remoteCommentId" editComment.autoSaveTextHelper.loadString(editComment) + // Prefill from the notification's inline-reply text, but never clobber an autosaved draft. + val prefill = arguments?.getString(KEY_PREFILL_REPLY_TEXT) + if (editComment.text.isNullOrEmpty() && !prefill.isNullOrEmpty()) { + editComment.setText(prefill) + } + // Opened via the notification's "reply" action: focus the reply field right away, like the + // legacy detail. Only on the first creation — not again after a rotation. + if (isFreshView && arguments?.getBoolean(KEY_FOCUS_REPLY_FIELD) == true) { + editComment.focusAndShowKeyboard() + } } private fun ReaderIncludeCommentBoxBinding.setupSuggestions() { @@ -238,7 +260,17 @@ class UnifiedCommentDetailsFragment : // Report the change to the launching screen (the rs comments list only refreshes when // the result says something actually changed). viewModel.commentChanged.observeEvent(viewLifecycleOwner) { - requireActivity().setResult(AppCompatActivity.RESULT_OK) + requireActivity().setResult(AppCompatActivity.RESULT_OK, resultIntent) + } + + // Note mode: attach the extras the notifications list uses to update the moderated + // note's row without waiting for its next refresh. + viewModel.commentModerated.observeEvent(viewLifecycleOwner) { status -> + noteId?.let { id -> + resultIntent.putExtra(NotificationsListFragment.NOTE_MODERATE_ID_EXTRA, id) + resultIntent.putExtra(NotificationsListFragment.NOTE_MODERATE_STATUS_EXTRA, status.toString()) + requireActivity().setResult(AppCompatActivity.RESULT_OK, resultIntent) + } } } @@ -491,7 +523,11 @@ class UnifiedCommentDetailsFragment : companion object { private const val KEY_REMOTE_COMMENT_ID = "key_remote_comment_id" + private const val KEY_NOTE_ID = "key_note_id" + private const val KEY_PREFILL_REPLY_TEXT = "key_prefill_reply_text" + private const val KEY_FOCUS_REPLY_FIELD = "key_focus_reply_field" + @JvmStatic fun newInstance(site: SiteModel, remoteCommentId: Long): UnifiedCommentDetailsFragment { return UnifiedCommentDetailsFragment().apply { arguments = Bundle().apply { @@ -500,5 +536,27 @@ class UnifiedCommentDetailsFragment : } } } + + /** + * Factory for the notifications host: [noteId] puts the screen in note mode (see + * [UnifiedCommentDetailsViewModel]), [prefillReplyText] carries the notification's + * inline-reply text and [focusReplyField] opens the keyboard on the reply field. + */ + @JvmStatic + fun newInstance( + site: SiteModel, + remoteCommentId: Long, + noteId: String, + prefillReplyText: String?, + focusReplyField: Boolean + ): UnifiedCommentDetailsFragment { + return newInstance(site, remoteCommentId).apply { + requireArguments().apply { + putString(KEY_NOTE_ID, noteId) + putString(KEY_PREFILL_REPLY_TEXT, prefillReplyText) + putBoolean(KEY_FOCUS_REPLY_FIELD, focusReplyField) + } + } + } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt index 22f8d361d34d..a2a801e91f65 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.wordpress.android.R +import org.wordpress.android.datasets.wrappers.NotificationsTableWrapper import org.wordpress.android.fluxc.model.CommentStatus import org.wordpress.android.fluxc.model.CommentStatus.APPROVED import org.wordpress.android.fluxc.model.CommentStatus.DELETED @@ -21,9 +22,11 @@ import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.Close import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.LaunchEditComment import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.OpenPostInReader import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.ReplySent +import org.wordpress.android.ui.comments.unified.CommentIdentifier.NotificationCommentIdentifier import org.wordpress.android.ui.comments.unified.CommentIdentifier.SiteCommentIdentifier import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsComment import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsResult +import org.wordpress.android.ui.notifications.utils.NotificationsActionsWrapper import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.utils.UiString import org.wordpress.android.ui.utils.UiString.UiStringRes @@ -36,7 +39,7 @@ import javax.inject.Inject import javax.inject.Named /** - * ViewModel for the unified comment detail screen (site-comments path). + * ViewModel for the unified comment detail screen. * * Loads and mutates the comment through wordpress-rs ([CommentsRsDataSource]) — view, moderation, * reply, edit. Two things stay on FluxC because wordpress-rs can't cover them: @@ -46,7 +49,11 @@ import javax.inject.Named * list reflects it. The cache row is also the source of the post title, like state and local id * used to launch the (still FluxC) edit screen. * - * Notification-sourced comments are handled in a later phase of the Comments Unification migration. + * **Note mode**: when [start] receives a `noteId` the comment was opened from a notification. + * Every successful write then also refreshes the note DB (so the notifications list stays fresh) + * and moderation additionally fires [commentModerated], which the host turns into the + * notifications-list result extras. Edits use [NotificationCommentIdentifier] so the edit screen + * refreshes the note too. */ class UnifiedCommentDetailsViewModel @Inject constructor( @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, @@ -55,12 +62,15 @@ class UnifiedCommentDetailsViewModel @Inject constructor( private val commentsStore: CommentsStore, private val localCommentCacheUpdateHandler: LocalCommentCacheUpdateHandler, private val networkUtilsWrapper: NetworkUtilsWrapper, - private val dateTimeUtilsWrapper: DateTimeUtilsWrapper + private val dateTimeUtilsWrapper: DateTimeUtilsWrapper, + private val notificationsActionsWrapper: NotificationsActionsWrapper, + private val notificationsTableWrapper: NotificationsTableWrapper ) : ScopedViewModel(mainDispatcher) { private val _uiState = MutableLiveData() private val _uiActionEvent = MutableLiveData>() private val _onSnackbarMessage = MutableLiveData>() private val _commentChanged = MutableLiveData>() + private val _commentModerated = MutableLiveData>() val uiState: LiveData = _uiState val uiActionEvent: LiveData> = _uiActionEvent @@ -73,20 +83,29 @@ class UnifiedCommentDetailsViewModel @Inject constructor( */ val commentChanged: LiveData> = _commentChanged + /** + * Fires with the new status when the comment was successfully moderated — note mode only. + * The notifications host reports it via the result extras the notifications list uses to + * update the moderated note's row. + */ + val commentModerated: LiveData> = _commentModerated + private var isStarted = false private lateinit var site: SiteModel private var remoteCommentId: Long = 0 + private var noteId: String? = null private var loadedComment: RsComment? = null private var isLikeInProgress = false // From the FluxC cache row: the local id used to launch the (still FluxC) edit screen. private var localCommentId: Int = 0 - fun start(site: SiteModel, remoteCommentId: Long) { + fun start(site: SiteModel, remoteCommentId: Long, noteId: String? = null) { if (isStarted) return isStarted = true this.site = site this.remoteCommentId = remoteCommentId + this.noteId = noteId loadComment() } @@ -110,7 +129,7 @@ class UnifiedCommentDetailsViewModel @Inject constructor( if (!isRefresh) { _uiState.value = CommentDetailsUiState(showProgress = true) } - val (rsComment, cached, fallbackPostTitle) = withContext(bgDispatcher) { + val loaded = withContext(bgDispatcher) { val rs = commentsRsDataSource.getComment(site, remoteCommentId) var local = commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId).firstOrNull() // Opened from the rs list the FluxC cache may not have this comment at all (the @@ -129,13 +148,24 @@ class UnifiedCommentDetailsViewModel @Inject constructor( } else { "" } - Triple(rs, local, fallbackTitle) + // No cache row to read the like state from (the fetch failed or was skipped): in + // note mode the note itself knows whether the comment is liked. + val likedFallback = if (local == null) { + noteId?.let { notificationsTableWrapper.getNoteById(it)?.hasLikedComment() } ?: false + } else { + false + } + CommentLoadResult(rs, local, fallbackTitle, likedFallback) } when { - rsComment != null -> { - loadedComment = rsComment - localCommentId = cached?.id?.toInt() ?: 0 - _uiState.value = rsComment.toUiState(cached, fallbackPostTitle) + loaded.rsComment != null -> { + loadedComment = loaded.rsComment + localCommentId = loaded.cached?.id?.toInt() ?: 0 + _uiState.value = loaded.rsComment.toUiState( + loaded.cached, + loaded.fallbackPostTitle, + loaded.fallbackIsLiked + ) } isRefresh -> showSnackbar(R.string.error_load_comment) else -> _onSnackbarMessage.value = Event( @@ -185,7 +215,10 @@ class UnifiedCommentDetailsViewModel @Inject constructor( _uiState.value = _uiState.value?.copy(isLiked = !isLike) showSnackbar(R.string.error_generic) } else { - withContext(bgDispatcher) { localCommentCacheUpdateHandler.requestCommentsUpdate() } + withContext(bgDispatcher) { + localCommentCacheUpdateHandler.requestCommentsUpdate() + refreshNote() + } } isLikeInProgress = false } @@ -193,9 +226,11 @@ class UnifiedCommentDetailsViewModel @Inject constructor( fun onEditClicked() { if (loadedComment == null) return - _uiActionEvent.value = Event( - LaunchEditComment(site, SiteCommentIdentifier(localCommentId, remoteCommentId)) - ) + // In note mode edit through the note identifier so the edit screen refreshes the note DB + // after saving, keeping the notifications list consistent with the edited comment. + val identifier = noteId?.let { NotificationCommentIdentifier(it, remoteCommentId) } + ?: SiteCommentIdentifier(localCommentId, remoteCommentId) + _uiActionEvent.value = Event(LaunchEditComment(site, identifier)) } fun onPostTitleClicked() { @@ -247,6 +282,8 @@ class UnifiedCommentDetailsViewModel @Inject constructor( val result = withContext(bgDispatcher) { moderate(APPROVED) } if (result is RsResult.Error) { _uiState.value = _uiState.value?.copy(status = UNAPPROVED) + } else if (noteId != null) { + _commentModerated.value = Event(APPROVED) } } @@ -265,6 +302,9 @@ class UnifiedCommentDetailsViewModel @Inject constructor( showError(result.message, R.string.error_moderate_comment) } else { _commentChanged.value = Event(Unit) + if (noteId != null) { + _commentModerated.value = Event(newStatus) + } if (closeOnSuccess) { _uiActionEvent.value = Event(Close) } @@ -292,10 +332,19 @@ class UnifiedCommentDetailsViewModel @Inject constructor( commentsStore.moderateCommentLocally(site, remoteCommentId, newStatus) } localCommentCacheUpdateHandler.requestCommentsUpdate() + refreshNote() } return result } + /** + * Note mode only: re-downloads the note so the note DB (which feeds the notifications list) + * reflects the change just made to the comment. + */ + private suspend fun refreshNote() { + noteId?.let { notificationsActionsWrapper.downloadNoteAndUpdateDB(it) } + } + private fun currentStatus(): CommentStatus = _uiState.value?.status ?: CommentStatus.ALL private fun isOffline(): Boolean { @@ -314,7 +363,11 @@ class UnifiedCommentDetailsViewModel @Inject constructor( _onSnackbarMessage.value = Event(SnackbarMessageHolder(uiMessage)) } - private fun RsComment.toUiState(cached: CommentEntity?, fallbackPostTitle: String) = CommentDetailsUiState( + private fun RsComment.toUiState( + cached: CommentEntity?, + fallbackPostTitle: String, + fallbackIsLiked: Boolean + ) = CommentDetailsUiState( showProgress = false, contentVisible = true, authorName = authorName, @@ -324,7 +377,14 @@ class UnifiedCommentDetailsViewModel @Inject constructor( postTitle = cached?.postTitle?.takeIf { it.isNotBlank() } ?: fallbackPostTitle, commentUrl = url, status = status, - isLiked = cached?.iLike ?: false + isLiked = cached?.iLike ?: fallbackIsLiked + ) + + private data class CommentLoadResult( + val rsComment: RsComment?, + val cached: CommentEntity?, + val fallbackPostTitle: String, + val fallbackIsLiked: Boolean ) data class CommentDetailsUiState( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java index 8ae0d432070c..f0254a11daa2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java @@ -40,6 +40,7 @@ import org.wordpress.android.ui.WPWebViewActivity; import org.wordpress.android.ui.comments.CommentActions; import org.wordpress.android.ui.comments.CommentDetailFragment; +import org.wordpress.android.ui.comments.unified.UnifiedCommentDetailsFragment; import org.wordpress.android.ui.engagement.EngagedPeopleListFragment; import org.wordpress.android.ui.engagement.ListScenarioUtils; import org.wordpress.android.ui.main.BaseAppCompatActivity; @@ -402,11 +403,23 @@ private Fragment createDetailFragmentForNote(@NonNull Note note) { // show comment detail for comment notifications boolean isInstantReply = getIntent().getBooleanExtra(NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA, false); - fragment = CommentDetailFragment.newInstance(note.getId(), - getIntent().getStringExtra(NotificationsListFragment.NOTE_PREFILLED_REPLY_EXTRA)); - - if (isInstantReply) { - ((CommentDetailFragment) fragment).enableShouldFocusReplyField(); + String prefilledReply = getIntent().getStringExtra(NotificationsListFragment.NOTE_PREFILLED_REPLY_EXTRA); + SiteModel site = mSiteStore.getSiteBySiteId(note.getSiteId()); + // The rs detail needs a real site from the SiteStore; the legacy fragment can fall + // back to a dummy WP.com site built from the note, so it stays the catch-all. + if (site != null && note.getCommentId() != 0 && ActivityLauncher.shouldUseRsComments(this, site)) { + fragment = UnifiedCommentDetailsFragment.newInstance( + site, + note.getCommentId(), + note.getId(), + prefilledReply, + isInstantReply + ); + } else { + fragment = CommentDetailFragment.newInstance(note.getId(), prefilledReply); + if (isInstantReply) { + ((CommentDetailFragment) fragment).enableShouldFocusReplyField(); + } } } else if (note.isAutomattcherType()) { // show reader post detail for automattchers about posts - note that comment diff --git a/WordPress/src/main/res/layout/unified_comment_details_fragment.xml b/WordPress/src/main/res/layout/unified_comment_details_fragment.xml index a059b80eca6d..a19675a4085d 100644 --- a/WordPress/src/main/res/layout/unified_comment_details_fragment.xml +++ b/WordPress/src/main/res/layout/unified_comment_details_fragment.xml @@ -3,7 +3,8 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/coordinator" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + android:background="?attr/colorSurface"> () private val uiActionEvents = mutableListOf() private val snackbarMessages = mutableListOf() + private val moderatedStatuses = mutableListOf() private val site = SiteModel().apply { id = LOCAL_SITE_ID @@ -81,6 +95,7 @@ class UnifiedCommentDetailsViewModelTest : BaseUnitTest() { whenever(commentsStore.likeComment(eq(site), eq(REMOTE_COMMENT_ID), eq(null), any())) .thenReturn(successPayload()) whenever(dateTimeUtilsWrapper.javaDateToTimeSpan(any())).thenReturn("2 hours ago") + whenever(notificationsActionsWrapper.downloadNoteAndUpdateDB(any())).thenReturn(true) viewModel = UnifiedCommentDetailsViewModel( mainDispatcher = testDispatcher(), @@ -89,7 +104,9 @@ class UnifiedCommentDetailsViewModelTest : BaseUnitTest() { commentsStore = commentsStore, localCommentCacheUpdateHandler = localCommentCacheUpdateHandler, networkUtilsWrapper = networkUtilsWrapper, - dateTimeUtilsWrapper = dateTimeUtilsWrapper + dateTimeUtilsWrapper = dateTimeUtilsWrapper, + notificationsActionsWrapper = notificationsActionsWrapper, + notificationsTableWrapper = notificationsTableWrapper ) setupObservers() @@ -355,14 +372,94 @@ class UnifiedCommentDetailsViewModelTest : BaseUnitTest() { assertThat(uiStates.last().status).isEqualTo(APPROVED) } + @Test + fun `onEditClicked uses the note identifier in note mode`() = test { + viewModel.start(site, REMOTE_COMMENT_ID, NOTE_ID) + + viewModel.onEditClicked() + + val event = uiActionEvents.last() + assertThat((event as LaunchEditComment).commentIdentifier) + .isEqualTo(NotificationCommentIdentifier(NOTE_ID, REMOTE_COMMENT_ID)) + } + + @Test + fun `moderation in note mode refreshes the note and fires commentModerated`() = test { + viewModel.start(site, REMOTE_COMMENT_ID, NOTE_ID) + + viewModel.onApproveClicked() + + verify(notificationsActionsWrapper).downloadNoteAndUpdateDB(NOTE_ID) + assertThat(moderatedStatuses).containsExactly(UNAPPROVED) + } + + @Test + fun `failed moderation in note mode neither refreshes the note nor fires commentModerated`() = test { + whenever(commentsRsDataSource.updateStatus(eq(site), eq(REMOTE_COMMENT_ID), any())) + .thenReturn(RsResult.Error("nope")) + viewModel.start(site, REMOTE_COMMENT_ID, NOTE_ID) + + viewModel.onApproveClicked() + + verifyNoInteractions(notificationsActionsWrapper) + assertThat(moderatedStatuses).isEmpty() + } + + @Test + fun `moderation without a note does not touch the notification wrappers`() = test { + viewModel.start(site, REMOTE_COMMENT_ID) + + viewModel.onApproveClicked() + + verifyNoInteractions(notificationsActionsWrapper) + verifyNoInteractions(notificationsTableWrapper) + assertThat(moderatedStatuses).isEmpty() + } + + @Test + fun `like in note mode refreshes the note`() = test { + viewModel.start(site, REMOTE_COMMENT_ID, NOTE_ID) + + viewModel.onLikeClicked() + + verify(notificationsActionsWrapper).downloadNoteAndUpdateDB(NOTE_ID) + } + + @Test + fun `replying to an unapproved comment in note mode fires commentModerated with approved`() = test { + whenever(commentsRsDataSource.getComment(site, REMOTE_COMMENT_ID)).thenReturn(UNAPPROVED_RS_COMMENT) + viewModel.start(site, REMOTE_COMMENT_ID, NOTE_ID) + + viewModel.onReplyClicked("nice post") + + assertThat(moderatedStatuses).containsExactly(APPROVED) + } + + @Test + fun `like state falls back to the note when the comment has no cache row`() = test { + val note = mock() + whenever(note.hasLikedComment()).thenReturn(true) + whenever(notificationsTableWrapper.getNoteById(NOTE_ID)).thenReturn(note) + whenever(commentsStore.getCommentByLocalSiteAndRemoteId(LOCAL_SITE_ID, REMOTE_COMMENT_ID)) + .thenReturn(emptyList()) + whenever(commentsRsDataSource.fetchPostTitles(site, listOf(REMOTE_POST_ID))) + .thenReturn(emptyMap()) + + viewModel.start(site, REMOTE_COMMENT_ID, NOTE_ID) + + assertThat(uiStates.last().isLiked).isTrue + } + private fun setupObservers() { uiStates.clear() uiActionEvents.clear() snackbarMessages.clear() + moderatedStatuses.clear() viewModel.uiState.observeForever { uiStates.add(it) } viewModel.uiActionEvent.observeForever { it.applyIfNotHandled { uiActionEvents.add(this) } } viewModel.onSnackbarMessage.observeForever { it.applyIfNotHandled { snackbarMessages.add(this) } } + viewModel.commentModerated.observeForever { it.applyIfNotHandled { moderatedStatuses.add(this) } } } private fun successPayload() = CommentsActionPayload(CommentsActionData(emptyList(), 0)) @@ -374,6 +471,7 @@ class UnifiedCommentDetailsViewModelTest : BaseUnitTest() { private const val REMOTE_COMMENT_ID = 4321L private const val REMOTE_POST_ID = 99L private const val LOAD_DELAY_MS = 1000L + private const val NOTE_ID = "note_5555" private val RS_COMMENT = RsComment( remoteCommentId = REMOTE_COMMENT_ID,