@@ -65,6 +65,7 @@ import org.wordpress.android.ui.uploads.PostEvents
6565import org.wordpress.android.ui.uploads.UploadService
6666import org.wordpress.android.ui.uploads.VideoOptimizer
6767import org.wordpress.android.ui.utils.UiString.UiStringRes
68+ import org.wordpress.android.ui.utils.UiString.UiStringText
6869import org.wordpress.android.util.AppLog
6970import org.wordpress.android.util.AppLog.T
7071import org.wordpress.android.util.SiteUtils
@@ -79,6 +80,7 @@ import javax.inject.Inject
7980
8081const val CONFIRM_DELETE_POST_DIALOG_TAG = " CONFIRM_DELETE_POST_DIALOG_TAG"
8182const val CONFIRM_PUBLISH_POST_DIALOG_TAG = " CONFIRM_PUBLISH_POST_DIALOG_TAG"
83+ const val CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG = " CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG"
8284
8385enum class PostListEmptyViewState {
8486 EMPTY_LIST ,
@@ -111,9 +113,12 @@ class PostListViewModel @Inject constructor(
111113
112114 // Keep a reference to the currently being trashed post, so we can hide it during Undo Snackbar
113115 private var postIdToTrash: Pair <Int , Long >? = null
114- // Since we are using DialogFragments we need to hold onto which post will be published or trashed
116+ // Since we are using DialogFragments we need to hold onto which post will be published or trashed / resolved
115117 private var localPostIdForPublishDialog: Int? = null
116118 private var localPostIdForTrashDialog: Int? = null
119+ private var localPostIdForConflictResolutionDialog: Int? = null
120+ private var originalPostCopyForConflictUndo: PostModel ? = null
121+ private var localPostIdForFetchingRemoteVersionOfConflictedPost: Int? = null
117122 // Initial target post to scroll to
118123 private var targetLocalPostId: Int? = null
119124
@@ -324,6 +329,18 @@ class PostListViewModel @Inject constructor(
324329 _dialogAction .postValue(dialogHolder)
325330 }
326331
332+ private fun showConflictedPostResolutionDialog (post : PostModel ) {
333+ val dialogHolder = DialogHolder (
334+ tag = CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG ,
335+ title = UiStringRes (R .string.dialog_confirm_load_remote_post_title),
336+ message = UiStringText (PostUtils .getConflictedPostCustomStringForDialog(post)),
337+ positiveButton = UiStringRes (R .string.dialog_confirm_load_remote_post_discard_local),
338+ negativeButton = UiStringRes (R .string.dialog_confirm_load_remote_post_discard_web)
339+ )
340+ localPostIdForConflictResolutionDialog = post.id
341+ _dialogAction .postValue(dialogHolder)
342+ }
343+
327344 private fun publishPost (localPostId : Int ) {
328345 val post = postStore.getPostByLocalPostId(localPostId)
329346 if (post != null ) {
@@ -337,6 +354,16 @@ class PostListViewModel @Inject constructor(
337354 }
338355
339356 private fun editPostButtonAction (site : SiteModel , post : PostModel ) {
357+ // first of all, check whether this post is in Conflicted state.
358+ if (doesPostHaveUnhandledConflict(post)) {
359+ showConflictedPostResolutionDialog(post)
360+ return
361+ }
362+
363+ checkGutenbergOrEdit(site, post)
364+ }
365+
366+ private fun checkGutenbergOrEdit (site : SiteModel , post : PostModel ) {
340367 // Show Gutenberg Warning Dialog if post contains GB blocks and it's not disabled
341368 if (! isGutenbergEnabled() &&
342369 PostUtils .contentContainsGutenbergBlocks(post.content) &&
@@ -406,6 +433,14 @@ class PostListViewModel @Inject constructor(
406433 T .POSTS ,
407434 " Error updating the post with type: ${event.error.type} and message: ${event.error.message} "
408435 )
436+ } else {
437+ originalPostCopyForConflictUndo?.id?.let {
438+ val updatedPost = postStore.getPostByLocalPostId(it)
439+ // Conflicted post has been successfully updated with its remote version
440+ if (! PostUtils .isPostInConflictWithRemote(updatedPost)) {
441+ conflictedPostUpdatedWithItsRemoteVersion()
442+ }
443+ }
409444 }
410445 }
411446 is CauseOfOnPostChanged .DeletePost -> {
@@ -525,13 +560,15 @@ class PostListViewModel @Inject constructor(
525560 date = PostUtils .getFormattedDate(post),
526561 postStatus = postStatus,
527562 isLocallyChanged = post.isLocallyChanged,
563+ isConflicted = doesPostHaveUnhandledConflict(post),
528564 canShowStats = canShowStats,
529565 canPublishPost = canPublishPost,
530566 canRetryUpload = uploadStatus.uploadError != null && ! uploadStatus.hasInProgressMediaUpload,
531567 featuredImageId = post.featuredImageId,
532568 featuredImageUrl = getFeaturedImageUrl(post.featuredImageId, post.content),
533569 uploadStatus = uploadStatus
534570 )
571+
535572 return PostAdapterItem (
536573 data = postData,
537574 onSelected = { handlePostButton(PostListButton .BUTTON_EDIT , post) },
@@ -601,6 +638,11 @@ class PostListViewModel @Inject constructor(
601638 localPostIdForPublishDialog = null
602639 publishPost(it)
603640 }
641+ CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG -> localPostIdForConflictResolutionDialog?.let {
642+ localPostIdForConflictResolutionDialog = null
643+ // here load version from remote
644+ updateConflictedPostWithItsRemoteVersion(it)
645+ }
604646 else -> throw IllegalArgumentException (" Dialog's positive button click is not handled: $instanceTag " )
605647 }
606648 }
@@ -609,13 +651,19 @@ class PostListViewModel @Inject constructor(
609651 when (instanceTag) {
610652 CONFIRM_DELETE_POST_DIALOG_TAG -> localPostIdForTrashDialog = null
611653 CONFIRM_PUBLISH_POST_DIALOG_TAG -> localPostIdForPublishDialog = null
654+ CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG -> localPostIdForConflictResolutionDialog?.let {
655+ updateConflictedPostWithItsLocalVersion(it)
656+ }
612657 else -> throw IllegalArgumentException (" Dialog's negative button click is not handled: $instanceTag " )
613658 }
614659 }
615660
616661 fun onDismissByOutsideTouchForBasicDialog (instanceTag : String ) {
617- // Cancel and outside touch dismiss works the same way
618- onNegativeClickedForBasicDialog(instanceTag)
662+ // Cancel and outside touch dismiss works the same way for all, except for conflict resolution dialog,
663+ // for which tapping outside and actively tapping the "edit local" have different meanings
664+ if (instanceTag != CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG ) {
665+ onNegativeClickedForBasicDialog(instanceTag)
666+ }
619667 }
620668
621669 // Gutenberg Events
@@ -662,8 +710,81 @@ class PostListViewModel @Inject constructor(
662710 }
663711 }
664712
713+ // Post Conflict Resolution
714+
715+ private fun updateConflictedPostWithItsRemoteVersion (localPostId : Int ) {
716+ // We need network connection to load a remote post
717+ if (! checkNetworkConnection()) {
718+ return
719+ }
720+
721+ val post = postStore.getPostByLocalPostId(localPostId)
722+ if (post != null ) {
723+ originalPostCopyForConflictUndo = post.clone()
724+ dispatcher.dispatch(PostActionBuilder .newFetchPostAction(RemotePostPayload (post, site)))
725+ _toastMessage .postValue(ToastMessageHolder (R .string.toast_conflict_updating_post, Duration .SHORT ))
726+ }
727+ }
728+
729+ private fun conflictedPostUpdatedWithItsRemoteVersion () {
730+ val undoAction = {
731+ // here replace the post with whatever we had before, again
732+ if (originalPostCopyForConflictUndo != null ) {
733+ dispatcher.dispatch(PostActionBuilder .newUpdatePostAction(originalPostCopyForConflictUndo))
734+ }
735+ }
736+ val onDismissAction = {
737+ originalPostCopyForConflictUndo = null
738+ }
739+ val snackbarHolder = SnackbarMessageHolder (R .string.snackbar_conflict_local_version_discarded,
740+ R .string.snackbar_conflict_undo, undoAction, onDismissAction)
741+ _snackbarAction .postValue(snackbarHolder)
742+ }
743+
744+ private fun updateConflictedPostWithItsLocalVersion (localPostId : Int ) {
745+ // We need network connection to push local version to remote
746+ if (! checkNetworkConnection()) {
747+ return
748+ }
749+
750+ // Keep a reference to which post is being updated with the local version so we can avoid showing the conflicted
751+ // label during the undo snackbar.
752+ localPostIdForFetchingRemoteVersionOfConflictedPost = localPostId
753+ pagedListWrapper.invalidateData()
754+
755+ val post = postStore.getPostByLocalPostId(localPostId) ? : return
756+
757+ // and now show a snackbar, acting as if the Post was pushed, but effectively push it after the snackbar is gone
758+ var isUndoed = false
759+ val undoAction = {
760+ isUndoed = true
761+
762+ // Remove the reference for the post being updated and re-show the conflicted label on undo
763+ localPostIdForFetchingRemoteVersionOfConflictedPost = null
764+ pagedListWrapper.invalidateData()
765+ }
766+
767+ val onDismissAction = {
768+ if (! isUndoed) {
769+ localPostIdForFetchingRemoteVersionOfConflictedPost = null
770+ PostUtils .trackSavePostAnalytics(post, site)
771+ dispatcher.dispatch(PostActionBuilder .newPushPostAction(RemotePostPayload (post, site)))
772+ }
773+ }
774+ val snackbarHolder = SnackbarMessageHolder (R .string.snackbar_conflict_web_version_discarded,
775+ R .string.snackbar_conflict_undo, undoAction, onDismissAction)
776+ _snackbarAction .postValue(snackbarHolder)
777+ }
778+
665779 // Utils
666780
781+ private fun doesPostHaveUnhandledConflict (post : PostModel ): Boolean {
782+ // If we are fetching the remote version of a conflicted post, it means it's already being handled
783+ val isFetchingConflictedPost = localPostIdForFetchingRemoteVersionOfConflictedPost != null &&
784+ localPostIdForFetchingRemoteVersionOfConflictedPost == post.id
785+ return ! isFetchingConflictedPost && PostUtils .isPostInConflictWithRemote(post)
786+ }
787+
667788 private fun checkNetworkConnection (): Boolean =
668789 if (isNetworkAvailable) {
669790 true
0 commit comments